Refactor: Rename NanoKVM to BatchuKVM and update server URL
This commit is contained in:
9
web/.editorconfig
Normal file
9
web/.editorconfig
Normal 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
|
||||
3
web/.env.development
Normal file
3
web/.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_SERVER_IP=192.168.0.65
|
||||
VITE_SERVER_PORT=80
|
||||
VITE_WITH_CREDENTIALS=false
|
||||
20
web/.eslintrc.cjs
Normal file
20
web/.eslintrc.cjs
Normal 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
1
web/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
*.hbs
|
||||
36
web/.prettierrc.yaml
Normal file
36
web/.prettierrc.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
singleQuote: true
|
||||
trailingComma: none
|
||||
printWidth: 100
|
||||
tabWidth: 2
|
||||
bracketSpacing: true
|
||||
importOrder:
|
||||
- ^(react/(.*)$)|^(react$)
|
||||
- ^(next/(.*)$)|^(next$)
|
||||
- <THIRD_PARTY_MODULES>
|
||||
- ''
|
||||
- ^types$
|
||||
- ^@/assets(.*)$
|
||||
- ^@/api/(.*)$
|
||||
- ^@/i18n/(.*)$
|
||||
- ^@/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
|
||||
63
web/README.md
Normal file
63
web/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# NanoKVM Frontend
|
||||
|
||||
This is NanoKVM web project. For more documentation, please refer to the [Wiki](https://wiki.sipeed.com/nanokvm).
|
||||
|
||||
## Structure
|
||||
|
||||
```shell
|
||||
src
|
||||
├── api // backend api
|
||||
├── assets // static resources
|
||||
├── components // public components
|
||||
├── i18n // language resources
|
||||
├── jotai // Global jotai variables
|
||||
├── lib // util libs
|
||||
├── pages // web pages
|
||||
│ ├── auth // login and password
|
||||
│ ├── desktop // remote desktop
|
||||
│ └── terminal // web terminal
|
||||
├── router.tsx // routers
|
||||
└── types // types
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
> Development requires SSH. You can enable it in the Web Settings: `Settings > SSH`.
|
||||
|
||||
Due to CORS restrictions, authentication needs to be disabled during local development.
|
||||
|
||||
To develop authentication features, you need to build the project and test in NanoKVM.
|
||||
|
||||
1. Log in to NanoKVM via SSH: `ssh root@your-nanokvm-ip` (default password is root).
|
||||
2. Open the configuration file `/etc/kvm/server.yaml/` and add `authentication: disable`. ⚠️CAUTION: This option disables all authentication and should NOT be enabled in production environment!
|
||||
3. Restart the service: `/etc/init.d/S95nanokvm restart`.
|
||||
4. Edit the `.env.development` file and change `VITE_SERVER_IP` to your NanoKVM IP address.
|
||||
5. Run `pnpm dev` to start the server and visit http://localhost:3001/ in browser.
|
||||
|
||||
|
||||
It's recommended to disable browser caching to avoid access issues during development:
|
||||
|
||||
1. Open the browser Developer Tools;
|
||||
2. Go to the `Network` tab;
|
||||
3. Check `Disable cache` option;
|
||||
4. Refresh the page.
|
||||
|
||||
## Deployment
|
||||
|
||||
Build:
|
||||
|
||||
```shell
|
||||
cd web
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
1. After the compilation is complete, the `dist` folder will be generated.
|
||||
2. Rename the folder to `web`.
|
||||
3. Upload `web` to `/kvmapp/server/` in NanoKVM.
|
||||
4. Restart the service by executing `/etc/init.d/S95nanokvm restart` in NanoKVM.
|
||||
|
||||
Tips:
|
||||
|
||||
1. File uploads requires SSH. You can enable it in the Web Settings: `Settings > SSH`.
|
||||
2. Browser may have old version cache. If you can't open the page, try a force refresh or clear the cache.
|
||||
63
web/README_JA.md
Normal file
63
web/README_JA.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# NanoKVM フロントエンド
|
||||
|
||||
これは NanoKVM のウェブプロジェクトです。詳細なドキュメントについては、[Wiki](https://wiki.sipeed.com/nanokvm) を参照してください。
|
||||
|
||||
## 構造
|
||||
|
||||
```shell
|
||||
src
|
||||
├── api // バックエンド API
|
||||
├── assets // 静的リソース
|
||||
├── components // 公共コンポーネント
|
||||
├── i18n // 言語リソース
|
||||
├── jotai // グローバル jotai 変数
|
||||
├── lib // ユーティリティライブラリ
|
||||
├── pages // ウェブページ
|
||||
│ ├── auth // ログインとパスワード
|
||||
│ ├── desktop // リモートデスクトップ
|
||||
│ └── terminal // ウェブターミナル
|
||||
├── router.tsx // ルーター
|
||||
└── types // 型定義
|
||||
```
|
||||
|
||||
## ローカル開発
|
||||
|
||||
> 開発には SSH が必要です。Web 設定で有効にすることができます: `設定 > SSH`
|
||||
|
||||
CORS 制限のため、ローカル開発中は認証を無効にする必要があります。
|
||||
|
||||
認証機能を開発するには、プロジェクトをビルドして NanoKVM でテストする必要があります。
|
||||
|
||||
1. SSH を介して NanoKVM にログインします:`ssh root@your-nanokvm-ip`(デフォルトのパスワードは root です)。
|
||||
2. 設定ファイル `/etc/kvm/server.yaml/` を開き、`authentication: disable` を追加します。⚠️注意:このオプションはすべての認証を無効にし、本番環境では有効にしないでください!
|
||||
3. サービスを再起動します:`/etc/init.d/S95nanokvm restart`。
|
||||
4. `.env.development` ファイルを編集し、`VITE_SERVER_IP` を NanoKVM の IP アドレスに変更します。
|
||||
5. `pnpm dev` を実行してサーバーを起動し、ブラウザで http://localhost:3001/ にアクセスします。
|
||||
|
||||
|
||||
開発中のアクセス問題を避けるため、ブラウザのキャッシュを無効にすることをお勧めします:
|
||||
|
||||
1. ブラウザの開発者ツールを開きます;
|
||||
2. `Network` タブに移動します;
|
||||
3. `Disable cache` オプションをチェックします;
|
||||
4. ページをリフレッシュします。
|
||||
|
||||
## デプロイ
|
||||
|
||||
ビルド:
|
||||
|
||||
```shell
|
||||
cd web
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
1. コンパイルが完了すると、`dist` フォルダが生成されます。
|
||||
2. フォルダの名前を `web` に変更します。
|
||||
3. `web` を NanoKVM の `/kvmapp/server/` にアップロードします。
|
||||
4. NanoKVM で `/etc/init.d/S95nanokvm restart` を実行してサービスを再起動します。
|
||||
|
||||
Tips:
|
||||
|
||||
1. ファイルのアップロードには SSH が必要です。Web 設定で有効にすることができます: `設定 > SSH`
|
||||
2. ブラウザに古いバージョンのキャッシュが残っている可能性があります。ページが開かない場合は、強制リフレッシュまたはキャッシュのクリアを試してください。
|
||||
64
web/README_ZH.md
Normal file
64
web/README_ZH.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# NanoKVM 前端页面
|
||||
|
||||
NanoKVM 前端页面的代码。更多文档请参考 [Wiki](https://wiki.sipeed.com/nanokvm) 。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```shell
|
||||
src
|
||||
├── api // 后端接口
|
||||
├── assets // 资源文件
|
||||
├── components // 公共组件
|
||||
├── i18n // 多语言
|
||||
├── jotai // 全局 jotai 变量
|
||||
├── lib // lib
|
||||
├── pages // 页面
|
||||
│ ├── auth // 鉴权页面
|
||||
│ ├── desktop // 远程桌面
|
||||
│ └── terminal // 终端
|
||||
├── router.tsx // 路由
|
||||
└── types // 类型定义
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
> 开发需要启用 SSH 功能。请在 Web `设置 - SSH` 中检查 SSH 是否已经启用。
|
||||
|
||||
由于 CORS 的限制,在本地开发时,需要关闭鉴权功能。
|
||||
|
||||
如果想要开发鉴权相关的功能,需要编译后在 NanoKVM 中进行测试。
|
||||
|
||||
1. 通过 SSH 登录到 NanoKVM:`ssh root@your-nanokvm-ip`(默认密码为 root);
|
||||
2. 修改配置文件 `/etc/kvm/server.yaml`,添加一行 `authentication: disable`。⚠️注意:该选项会禁用所有鉴权功能,生产环境请勿开启该选项!
|
||||
3. 执行 `/etc/init.d/S95nanokvm restart` 重启服务。
|
||||
4. 编辑 `.env.development` 文件,将 `VITE_SERVER_IP` 修改为你的 NanoKVM IP 地址。
|
||||
5. 执行 `pnpm dev` 启动服务,然后在浏览器中访问 http://localhost:3001/ 。
|
||||
|
||||
|
||||
建议在浏览器中禁用缓存,防止在开发过程中出现无法访问的情况。
|
||||
|
||||
1. 打开开发者工具;
|
||||
2. 点击 `Network` 选项卡;
|
||||
3. 勾选 `Disable cache` 选项;
|
||||
4. 刷新页面。
|
||||
|
||||
## 部署
|
||||
|
||||
编译:
|
||||
|
||||
```shell
|
||||
cd web
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
1. 编译完成后会生成 `dist` 文件夹;
|
||||
2. 将该文件夹重命名为 `web`;
|
||||
3. 将 `web` 文件夹上传到 NanoKVM 的 `/kvmapp/server/` 目录下;
|
||||
4. 在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务。
|
||||
|
||||
|
||||
注意:
|
||||
|
||||
1. 上传文件需要启用 SSH 功能。请在 Web `设置 - SSH` 中检查 SSH 是否已经启用。
|
||||
2. 更新 web 目录后,浏览器可能会有缓存。如果遇到打不开页面的情况,请强制刷新或清空缓存。
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal 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>BatchuKVM</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
71
web/package.json
Normal file
71
web/package.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"mocked": "vite --mode mocked",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"antd": "^5.29.1",
|
||||
"axios": "1.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"i18next": "^23.16.4",
|
||||
"jotai": "^2.10.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^14.1.3",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-keyboard": "^3.8.19",
|
||||
"semver": "^7.6.3",
|
||||
"vaul": "^0.9.9",
|
||||
"websocket": "^1.0.35",
|
||||
"yocto-queue": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/websocket": "^1.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"msw": "^2.6.0",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "7.1.11",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
6202
web/pnpm-lock.yaml
generated
Normal file
6202
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
293
web/public/mockServiceWorker.js
Normal file
293
web/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,293 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.6.0'
|
||||
const INTEGRITY_CHECKSUM = '07a8241b182f8a246a7cd39894799a9e'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId))
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
body: responseClone.body,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
},
|
||||
},
|
||||
[responseClone.body],
|
||||
)
|
||||
})()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = request.clone()
|
||||
|
||||
function passthrough() {
|
||||
const headers = Object.fromEntries(requestClone.headers.entries())
|
||||
|
||||
// Remove internal MSW request header so the passthrough request
|
||||
// complies with any potential CORS preflight checks on the server.
|
||||
// Some servers forbid unknown request headers.
|
||||
delete headers['x-msw-intention']
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const requestBuffer = await request.arrayBuffer()
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: requestBuffer,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
},
|
||||
[requestBuffer],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(
|
||||
message,
|
||||
[channel.port2].concat(transferrables.filter(Boolean)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
BIN
web/public/sipeed.ico
Normal file
BIN
web/public/sipeed.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
29
web/src/api/application.ts
Normal file
29
web/src/api/application.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
// get application version
|
||||
export function getVersion() {
|
||||
return http.get('/api/application/version');
|
||||
}
|
||||
|
||||
// update application to latest version
|
||||
export function update() {
|
||||
return http.request({
|
||||
method: 'post',
|
||||
url: '/api/application/update',
|
||||
timeout: 15 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
// enable/disable preview updates
|
||||
export function setPreviewUpdates(enable: boolean) {
|
||||
const data = {
|
||||
enable
|
||||
};
|
||||
return http.post('/api/application/preview', data);
|
||||
}
|
||||
|
||||
// get preview updates state
|
||||
export function getPreviewUpdates() {
|
||||
return http.get('/api/application/preview');
|
||||
}
|
||||
|
||||
29
web/src/api/auth.ts
Normal file
29
web/src/api/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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 logout() {
|
||||
return http.post('/api/auth/logout');
|
||||
}
|
||||
|
||||
export function getAccount() {
|
||||
return http.get('/api/auth/account');
|
||||
}
|
||||
|
||||
export function changePassword(username: string, password: string) {
|
||||
const data = {
|
||||
username,
|
||||
password
|
||||
};
|
||||
return http.post('/api/auth/password', data);
|
||||
}
|
||||
|
||||
export function isPasswordUpdated() {
|
||||
return http.get('/api/auth/password');
|
||||
}
|
||||
17
web/src/api/download.ts
Normal file
17
web/src/api/download.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
// Download image
|
||||
export function downloadImage(file?: string) {
|
||||
const data = {
|
||||
file: file ? file : ''
|
||||
};
|
||||
return http.post('/api/download/image', data);
|
||||
}
|
||||
|
||||
export function statusImage() {
|
||||
return http.get('/api/download/image/status');
|
||||
}
|
||||
|
||||
export function imageEnabled() {
|
||||
return http.get('/api/download/image/enabled');
|
||||
}
|
||||
51
web/src/api/extensions/tailscale.ts
Normal file
51
web/src/api/extensions/tailscale.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
// install tailscale
|
||||
export function install() {
|
||||
return http.post('/api/extensions/tailscale/install');
|
||||
}
|
||||
|
||||
// uninstall tailscale
|
||||
export function uninstall() {
|
||||
return http.post('/api/extensions/tailscale/uninstall');
|
||||
}
|
||||
|
||||
// get tailscale status
|
||||
export function getStatus() {
|
||||
return http.get('/api/extensions/tailscale/status');
|
||||
}
|
||||
|
||||
// start tailscale
|
||||
export function start() {
|
||||
return http.post('/api/extensions/tailscale/start');
|
||||
}
|
||||
|
||||
// restart tailscale
|
||||
export function restart() {
|
||||
return http.post('/api/extensions/tailscale/restart');
|
||||
}
|
||||
|
||||
// stop tailscale
|
||||
export function stop() {
|
||||
return http.post('/api/extensions/tailscale/stop');
|
||||
}
|
||||
|
||||
// run tailscale up
|
||||
export function up() {
|
||||
return http.post('/api/extensions/tailscale/up');
|
||||
}
|
||||
|
||||
// run tailscale down
|
||||
export function down() {
|
||||
return http.post('/api/extensions/tailscale/down');
|
||||
}
|
||||
|
||||
// login tailscale
|
||||
export function login() {
|
||||
return http.post('/api/extensions/tailscale/login');
|
||||
}
|
||||
|
||||
// logout tailscale
|
||||
export function logout() {
|
||||
return http.post('/api/extensions/tailscale/logout');
|
||||
}
|
||||
24
web/src/api/hid.ts
Normal file
24
web/src/api/hid.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
// paste
|
||||
export function paste(content: string, langue: string) {
|
||||
return http.post('/api/hid/paste', { content, langue });
|
||||
}
|
||||
|
||||
// reset hid
|
||||
export function reset() {
|
||||
return http.post('/api/hid/reset');
|
||||
}
|
||||
|
||||
// get hid mode
|
||||
export function getHidMode() {
|
||||
return http.get('/api/hid/mode');
|
||||
}
|
||||
|
||||
// set hid mode
|
||||
export function setHidMode(mode: string) {
|
||||
const data = {
|
||||
mode
|
||||
};
|
||||
return http.post('/api/hid/mode', data);
|
||||
}
|
||||
41
web/src/api/network.ts
Normal file
41
web/src/api/network.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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 }
|
||||
});
|
||||
}
|
||||
|
||||
// set Mac name
|
||||
export function setWolMacName(mac: string, name: string) {
|
||||
return http.post('/api/network/wol/mac/name', { mac, name });
|
||||
}
|
||||
|
||||
// get wifi information
|
||||
export function getWiFi() {
|
||||
return http.get('/api/network/wifi');
|
||||
}
|
||||
|
||||
// connect wifi
|
||||
export function connectWifi(ssid: string, password: string) {
|
||||
const data = {
|
||||
ssid,
|
||||
password
|
||||
};
|
||||
return http.post('/api/network/wifi', data);
|
||||
}
|
||||
28
web/src/api/script.ts
Normal file
28
web/src/api/script.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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) {
|
||||
return http.post('/api/vm/script/run', { name, type });
|
||||
}
|
||||
|
||||
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 }
|
||||
});
|
||||
}
|
||||
32
web/src/api/storage.ts
Normal file
32
web/src/api/storage.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
// get image list
|
||||
export function getImages() {
|
||||
return http.get('/api/storage/image');
|
||||
}
|
||||
|
||||
// get mounted image
|
||||
export function getMountedImage() {
|
||||
return http.get('/api/storage/image/mounted');
|
||||
}
|
||||
|
||||
// mount/unmount image
|
||||
export function mountImage(file?: string, cdrom?: boolean) {
|
||||
const data = {
|
||||
file: file ? file : '',
|
||||
cdrom: cdrom
|
||||
};
|
||||
return http.post('/api/storage/image/mount', data);
|
||||
}
|
||||
|
||||
// get CD-ROM flag
|
||||
export function getCdRom() {
|
||||
return http.get('/api/storage/cdrom');
|
||||
}
|
||||
|
||||
export function deleteImage(file: string) {
|
||||
const data = {
|
||||
file
|
||||
};
|
||||
return http.post('/api/storage/image/delete', data);
|
||||
}
|
||||
17
web/src/api/stream.ts
Normal file
17
web/src/api/stream.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
// enable/disable frame detect
|
||||
export function updateFrameDetect(enabled: boolean) {
|
||||
const data = {
|
||||
enabled
|
||||
};
|
||||
return http.post('/api/stream/mjpeg/detect', data);
|
||||
}
|
||||
|
||||
// pause frame detect for a while (prevent a black screen when opening the page for the first time)
|
||||
export function stopFrameDetect(duration: number) {
|
||||
const data = {
|
||||
duration
|
||||
};
|
||||
return http.post('/api/stream/mjpeg/detect/stop', data);
|
||||
}
|
||||
15
web/src/api/virtual-device.ts
Normal file
15
web/src/api/virtual-device.ts
Normal 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);
|
||||
}
|
||||
166
web/src/api/vm.ts
Normal file
166
web/src/api/vm.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
// get NanoKVM information
|
||||
export function getInfo() {
|
||||
return http.get('/api/vm/info');
|
||||
}
|
||||
|
||||
// get hardware information
|
||||
export function getHardware() {
|
||||
return http.get('/api/vm/hardware');
|
||||
}
|
||||
|
||||
// set gpio value
|
||||
export function setGpio(type: string, duration: number) {
|
||||
const data = {
|
||||
type,
|
||||
duration
|
||||
};
|
||||
return http.post('/api/vm/gpio', data);
|
||||
}
|
||||
|
||||
// get gpio value
|
||||
export function getGpio() {
|
||||
return http.get('/api/vm/gpio');
|
||||
}
|
||||
|
||||
// update screen arguments
|
||||
export function updateScreen(type: string, value: number) {
|
||||
const data = {
|
||||
type,
|
||||
value
|
||||
};
|
||||
return http.post('/api/vm/screen', data);
|
||||
}
|
||||
|
||||
// get memory limit
|
||||
export function getMemoryLimit() {
|
||||
return http.get('/api/vm/memory/limit');
|
||||
}
|
||||
|
||||
// set memory limit
|
||||
export function setMemoryLimit(enabled: boolean, limit: number) {
|
||||
const data = {
|
||||
enabled,
|
||||
limit
|
||||
};
|
||||
return http.post('/api/vm/memory/limit', data);
|
||||
}
|
||||
|
||||
// get OLED configuration
|
||||
export function getOLED() {
|
||||
return http.get('/api/vm/oled');
|
||||
}
|
||||
|
||||
// set OLED configuration
|
||||
export function setOLED(sleep: number) {
|
||||
return http.post('/api/vm/oled', { sleep });
|
||||
}
|
||||
|
||||
// reset HDMI
|
||||
export function resetHdmi() {
|
||||
return http.post('/api/vm/hdmi/reset');
|
||||
}
|
||||
|
||||
// get HDMI state
|
||||
export function getHdmiState() {
|
||||
return http.get('/api/vm/hdmi');
|
||||
}
|
||||
|
||||
// enable HDMI
|
||||
export function enableHdmi() {
|
||||
return http.post('/api/vm/hdmi/enable');
|
||||
}
|
||||
|
||||
// disable HDMI
|
||||
export function disableHdmi() {
|
||||
return http.post('/api/vm/hdmi/disable');
|
||||
}
|
||||
|
||||
// set HDMI state
|
||||
export function setHdmiState(enabled: boolean) {
|
||||
if (enabled) {
|
||||
return enableHdmi();
|
||||
}
|
||||
return disableHdmi();
|
||||
}
|
||||
|
||||
// get SSH state
|
||||
export function getSSHState() {
|
||||
return http.get('/api/vm/ssh');
|
||||
}
|
||||
|
||||
// enable SSH
|
||||
export function enableSSH() {
|
||||
return http.post('/api/vm/ssh/enable');
|
||||
}
|
||||
|
||||
// disable SSH
|
||||
export function disableSSH() {
|
||||
return http.post('/api/vm/ssh/disable');
|
||||
}
|
||||
|
||||
// get swap file size
|
||||
export function getSwap() {
|
||||
return http.get('/api/vm/swap');
|
||||
}
|
||||
|
||||
// set swap file size
|
||||
export function setSwap(size: number) {
|
||||
return http.post('/api/vm/swap', { size });
|
||||
}
|
||||
|
||||
// get mouse jiggler
|
||||
export function getMouseJiggler() {
|
||||
return http.get('/api/vm/mouse-jiggler');
|
||||
}
|
||||
|
||||
// set mouse jiggler
|
||||
export function setMouseJiggler(enabled: boolean, mode: string) {
|
||||
return http.post('/api/vm/mouse-jiggler', { enabled, mode });
|
||||
}
|
||||
|
||||
// get Hostname
|
||||
export function getHostname() {
|
||||
return http.get('/api/vm/hostname');
|
||||
}
|
||||
|
||||
// set Hostname
|
||||
export function setHostname(hostname: string) {
|
||||
return http.post('/api/vm/hostname', { hostname });
|
||||
}
|
||||
|
||||
// get WebTitle
|
||||
export function getWebTitle() {
|
||||
return http.get('/api/vm/web-title');
|
||||
}
|
||||
|
||||
// set WebTitle
|
||||
export function setWebTitle(title: string) {
|
||||
return http.post('/api/vm/web-title', { title });
|
||||
}
|
||||
|
||||
// get mDNS state
|
||||
export function getMdnsState() {
|
||||
return http.get('/api/vm/mdns');
|
||||
}
|
||||
|
||||
// enable mDNS
|
||||
export function enableMdns() {
|
||||
return http.post('/api/vm/mdns/enable');
|
||||
}
|
||||
|
||||
// disable mDNS
|
||||
export function disableMdns() {
|
||||
return http.post('/api/vm/mdns/disable');
|
||||
}
|
||||
|
||||
// enable / disable TLS
|
||||
export function setTLS(enabled: boolean) {
|
||||
return http.post('/api/vm/tls', { enabled });
|
||||
}
|
||||
|
||||
// reboot
|
||||
export function reboot() {
|
||||
return http.post('/api/vm/system/reboot');
|
||||
}
|
||||
1
web/src/assets/images/monitor-x.svg
Normal file
1
web/src/assets/images/monitor-x.svg
Normal 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 |
11
web/src/assets/images/tailscale.svg
Normal file
11
web/src/assets/images/tailscale.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg t="1736133022278" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4848" width="200" height="200">
|
||||
<path d="M512 512m-85.36064 0a85.36064 85.36064 0 1 0 170.72128 0 85.36064 85.36064 0 1 0-170.72128 0Z" fill="#FFFFFF" p-id="4849"></path>
|
||||
<path d="M259.35872 259.35872m-85.36064 0a85.36064 85.36064 0 1 0 170.72128 0 85.36064 85.36064 0 1 0-170.72128 0Z" fill="#FFFFFF" fill-opacity=".2" p-id="4850"></path>
|
||||
<path d="M764.64128 259.35872m-85.36064 0a85.36064 85.36064 0 1 0 170.72128 0 85.36064 85.36064 0 1 0-170.72128 0Z" fill="#FFFFFF" fill-opacity=".2" p-id="4851"></path>
|
||||
<path d="M764.64128 512m-85.36064 0a85.36064 85.36064 0 1 0 170.72128 0 85.36064 85.36064 0 1 0-170.72128 0Z" fill="#FFFFFF" p-id="4852"></path>
|
||||
<path d="M764.64128 764.64128m-85.36064 0a85.36064 85.36064 0 1 0 170.72128 0 85.36064 85.36064 0 1 0-170.72128 0Z" fill="#FFFFFF" fill-opacity=".2" p-id="4853"></path>
|
||||
<path d="M512 259.35872m-85.36064 0a85.36064 85.36064 0 1 0 170.72128 0 85.36064 85.36064 0 1 0-170.72128 0Z" fill="#FFFFFF" fill-opacity=".2" p-id="4854"></path>
|
||||
<path d="M259.35872 764.64128m-85.36064 0a85.36064 85.36064 0 1 0 170.72128 0 85.36064 85.36064 0 1 0-170.72128 0Z" fill="#FFFFFF" fill-opacity=".2" p-id="4855"></path>
|
||||
<path d="M259.35872 512m-85.36064 0a85.36064 85.36064 0 1 0 170.72128 0 85.36064 85.36064 0 1 0-170.72128 0Z" fill="#FFFFFF" p-id="4856"></path>
|
||||
<path d="M512 764.64128m-85.36064 0a85.36064 85.36064 0 1 0 170.72128 0 85.36064 85.36064 0 1 0-170.72128 0Z" fill="#FFFFFF" p-id="4857"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
36
web/src/assets/styles/index.css
Normal file
36
web/src/assets/styles/index.css
Normal file
@@ -0,0 +1,36 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
109
web/src/assets/styles/keyboard.css
Normal file
109
web/src/assets/styles/keyboard.css
Normal file
@@ -0,0 +1,109 @@
|
||||
.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: 250px;
|
||||
}
|
||||
|
||||
.hg-layout-mac .hg-button.hg-functionBtn.hg-button-space {
|
||||
width: 350px;
|
||||
}
|
||||
|
||||
.simple-keyboard .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-button.hg-double{
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.keyboard-header {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
.hg-candidate-box {
|
||||
display: none !important;
|
||||
}
|
||||
14
web/src/components/auth.tsx
Normal file
14
web/src/components/auth.tsx
Normal 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;
|
||||
};
|
||||
38
web/src/components/head.tsx
Normal file
38
web/src/components/head.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Helmet, HelmetData } from 'react-helmet-async';
|
||||
|
||||
import { getWebTitle } from '@/api/vm.ts';
|
||||
import { existToken } from '@/lib/cookie.ts';
|
||||
import { webTitleAtom } from '@/jotai/settings.ts';
|
||||
|
||||
type HeadProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const helmetData = new HelmetData({});
|
||||
|
||||
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
|
||||
const [webTitle, setWebTitle] = useAtom(webTitleAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!existToken()) return;
|
||||
|
||||
getWebTitle().then((rsp) => {
|
||||
if (rsp.data?.title) {
|
||||
setWebTitle(rsp.data.title);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Helmet
|
||||
helmetData={helmetData}
|
||||
title={webTitle ? webTitle : title ? `${title} - BatchuKVM` : undefined}
|
||||
defaultTitle={webTitle}
|
||||
>
|
||||
<meta name="description" content={description} />
|
||||
</Helmet>
|
||||
);
|
||||
};
|
||||
5
web/src/components/icons/tailscale.tsx
Normal file
5
web/src/components/icons/tailscale.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import icon from '@/assets/images/tailscale.svg';
|
||||
|
||||
export const Tailscale = () => {
|
||||
return <img src={icon} className="h-[18px] w-[18px]" alt="tailscale" />;
|
||||
};
|
||||
18
web/src/components/main-error.tsx
Normal file
18
web/src/components/main-error.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Button } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const MainError = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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">{t('error.title')}</h2>
|
||||
<Button type="primary" danger onClick={() => window.location.assign(window.location.origin)}>
|
||||
{t('error.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
web/src/components/menu-item.tsx
Normal file
72
web/src/components/menu-item.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
type MenuItemProps = {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
content: ReactNode;
|
||||
className?: string;
|
||||
fresh?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const MenuItem = ({
|
||||
title,
|
||||
icon,
|
||||
content,
|
||||
className,
|
||||
fresh,
|
||||
onOpenChange
|
||||
}: MenuItemProps) => {
|
||||
const isBigScreen = useMediaQuery({ minWidth: 640 });
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
|
||||
function togglePopover(open: boolean) {
|
||||
setIsTooltipOpen(false);
|
||||
setIsPopoverOpen(open);
|
||||
|
||||
if (onOpenChange) {
|
||||
onOpenChange(open);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTooltip(open: boolean) {
|
||||
if (isPopoverOpen) {
|
||||
return;
|
||||
}
|
||||
setIsTooltipOpen(open);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
arrow={false}
|
||||
trigger="click"
|
||||
placement={isBigScreen ? 'bottomLeft' : 'bottom'}
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={togglePopover}
|
||||
fresh={!!fresh}
|
||||
>
|
||||
<Tooltip
|
||||
title={title}
|
||||
mouseEnterDelay={0.6}
|
||||
placement="bottom"
|
||||
open={isTooltipOpen}
|
||||
onOpenChange={toggleTooltip}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
className
|
||||
? className
|
||||
: 'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white'
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
9
web/src/components/root.tsx
Normal file
9
web/src/components/root.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export const Root = () => {
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
web/src/i18n/README.md
Normal file
4
web/src/i18n/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# How to add a language
|
||||
|
||||
1. Add a language file in i18n/locales folder (for example: en.ts).
|
||||
2. Add language key and name in i18n/languages.ts (for example: { key: 'en', name: 'English' }).
|
||||
53
web/src/i18n/index.ts
Normal file
53
web/src/i18n/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import i18n from 'i18next';
|
||||
import type { Resource } from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { getLanguage } from '@/lib/localstorage.ts';
|
||||
|
||||
function getResources(): Resource {
|
||||
const resources: Resource = {};
|
||||
|
||||
const modules: Record<string, Resource> = import.meta.glob('./locales/*.ts', { eager: true });
|
||||
|
||||
for (const path in modules) {
|
||||
const moduleName = path.split('/').pop()?.replace('.ts', '');
|
||||
if (moduleName) {
|
||||
resources[moduleName] = modules[path].default;
|
||||
}
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
function getCurrentLanguage(): string {
|
||||
const languages = Object.keys(resources);
|
||||
|
||||
const cookieLng = getLanguage();
|
||||
if (cookieLng && languages.includes(cookieLng)) {
|
||||
return cookieLng;
|
||||
}
|
||||
|
||||
const navigatorLng = navigator.language.split('-')[0];
|
||||
if (languages.includes(navigatorLng)) {
|
||||
return navigatorLng;
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
|
||||
const resources = getResources();
|
||||
const lng = getCurrentLanguage();
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
lng,
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
})
|
||||
.then();
|
||||
|
||||
export default i18n;
|
||||
8
web/src/i18n/languages.ts
Normal file
8
web/src/i18n/languages.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
const languages = [
|
||||
{ key: 'en', name: 'English' },
|
||||
{ key: 'ko', name: '한국어' },
|
||||
];
|
||||
|
||||
languages.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));
|
||||
|
||||
export default languages;
|
||||
372
web/src/i18n/locales/en.ts
Normal file
372
web/src/i18n/locales/en.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
const en = {
|
||||
translation: {
|
||||
head: {
|
||||
desktop: 'Remote Desktop',
|
||||
login: 'Login',
|
||||
changePassword: 'Change Password',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
},
|
||||
auth: {
|
||||
login: 'Login',
|
||||
placeholderUsername: 'Username',
|
||||
placeholderPassword: 'Password',
|
||||
placeholderPassword2: 'Please enter password again',
|
||||
noEmptyUsername: 'Username required',
|
||||
noEmptyPassword: 'Password required',
|
||||
noAccount: 'Failed to get user information, please refresh web page or reset password',
|
||||
invalidUser: 'Invalid username or password',
|
||||
error: 'Unexpected error',
|
||||
changePassword: 'Change Password',
|
||||
changePasswordDesc: 'For the security of your device, please change the password!',
|
||||
differentPassword: 'Passwords do not match',
|
||||
illegalUsername: 'Username contains illegal characters',
|
||||
illegalPassword: 'Password contains illegal characters',
|
||||
forgetPassword: 'Forgot Password',
|
||||
ok: 'Ok',
|
||||
cancel: 'Cancel',
|
||||
loginButtonText: 'Login',
|
||||
tips: {
|
||||
reset1:
|
||||
'To reset the passwords, press and hold the BOOT button on the BatchuKVM for 10 seconds.',
|
||||
reset2: 'For detailed steps, please consult this document:',
|
||||
reset3: 'Web default account:',
|
||||
reset4: 'SSH default account:',
|
||||
change1: 'Please note that this action will change the following passwords:',
|
||||
change2: 'Web login password',
|
||||
change3: 'System root password (SSH login password)',
|
||||
change4: 'To reset the passwords, press and hold the BOOT button on the BatchuKVM.'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi for BatchuKVM',
|
||||
success: 'Please check the network status of BatchuKVM and visit the new IP address.',
|
||||
failed: 'Operation failed, please try again.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Finished'
|
||||
},
|
||||
screen: {
|
||||
title: 'Screen',
|
||||
video: 'Video Mode',
|
||||
videoDirectTips: 'Enable HTTPS in "Settings > Device" to use this mode',
|
||||
resolution: 'Resolution',
|
||||
auto: 'Automatic',
|
||||
autoTips:
|
||||
"Screen tearing or mouse offset may occur at specific resolutions. Consider adjusting the remote host's resolution or disable automatic mode.",
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Customize',
|
||||
quality: 'Quality',
|
||||
qualityLossless: 'Lossless',
|
||||
qualityHigh: 'High',
|
||||
qualityMedium: 'Medium',
|
||||
qualityLow: 'Low',
|
||||
frameDetect: 'Frame Detect',
|
||||
frameDetectTip:
|
||||
"Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen.",
|
||||
resetHdmi: 'Reset HDMI'
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Keyboard',
|
||||
paste: 'Paste',
|
||||
tips: 'Only standard keyboard letters and symbols are supported',
|
||||
placeholder: 'Please input',
|
||||
submit: 'Submit',
|
||||
virtual: 'Keyboard',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del',
|
||||
readClipboard: 'Read from Clipboard',
|
||||
clipboardPermissionDenied:
|
||||
'Clipboard permission denied. Please allow clipboard access in your browser.',
|
||||
clipboardReadError: 'Failed to read clipboard',
|
||||
dropdownEnglish: 'English',
|
||||
dropdownGerman: 'German'
|
||||
},
|
||||
mouse: {
|
||||
title: 'Mouse',
|
||||
cursor: 'Cursor style',
|
||||
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',
|
||||
speed: 'Wheel speed',
|
||||
fast: 'Fast',
|
||||
slow: 'Slow',
|
||||
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.',
|
||||
resetHid: 'Reset HID',
|
||||
hidOnly: {
|
||||
title: 'HID-Only mode',
|
||||
desc: "If your mouse and keyboard stop responding and resetting HID doesn't help, it could be a compatibility issue between the BatchuKVM and the device. Try to enable HID-Only mode for better compatibility.",
|
||||
tip1: 'Enabling HID-Only mode will unmount the virtual U-disk and virtual network',
|
||||
tip2: 'In HID-Only mode, image mounting is disabled',
|
||||
tip3: 'BatchuKVM will automatically reboot after switching modes',
|
||||
enable: 'Enable HID-Only mode',
|
||||
disable: 'Disable HID-Only mode'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Images',
|
||||
loading: 'Loading...',
|
||||
empty: 'Nothing Found',
|
||||
mountMode: 'Mount mode',
|
||||
mountFailed: 'Mount failed',
|
||||
mountDesc:
|
||||
'On some systems, you need to eject the virtual disk from the remote host before mounting the image.',
|
||||
unmountFailed: 'Unmount failed',
|
||||
unmountDesc:
|
||||
'On some systems, you need to manually eject from the remote host before unmounting the image.',
|
||||
refresh: 'Refresh the image list',
|
||||
attention: 'Attention',
|
||||
deleteConfirm: 'Are you sure you want to delete this image?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No',
|
||||
tips: {
|
||||
title: 'How to upload',
|
||||
usb1: 'Connect the BatchuKVM to your computer via USB.',
|
||||
usb2: 'Ensure that the virtual disk is mounted (Settings - Virtual Disk).',
|
||||
usb3: 'Open the virtual disk on your computer and copy the image file to the root directory of the virtual disk.',
|
||||
scp1: 'Make sure the BatchuKVM and your computer are on the same local network.',
|
||||
scp2: 'Open a terminal on your computer and use the SCP command to upload the image file to the /data directory on the BatchuKVM.',
|
||||
scp3: 'Example: scp your-image-path root@your-BatchuKVM-ip:/data',
|
||||
tfCard: 'TF Card',
|
||||
tf1: 'This method is supported on Linux system',
|
||||
tf2: 'Get TF card from the BatchuKVM (for the FULL version, disassemble the case first).',
|
||||
tf3: 'Insert the TF card into a card reader and connect it to your computer.',
|
||||
tf4: 'Copy the image file to the /data directory on the TF card.',
|
||||
tf5: 'Insert the TF card into the BatchuKVM.'
|
||||
}
|
||||
},
|
||||
script: {
|
||||
title: 'Scripts',
|
||||
upload: 'Upload',
|
||||
run: 'Run',
|
||||
runBackground: 'Run Background',
|
||||
runFailed: 'Run failed',
|
||||
attention: 'Attention',
|
||||
delDesc: 'Are you sure you want to delete this file?',
|
||||
confirm: 'Yes',
|
||||
cancel: 'No',
|
||||
delete: 'Delete',
|
||||
close: 'Close'
|
||||
},
|
||||
terminal: {
|
||||
title: 'Terminal',
|
||||
BatchuKVM: 'BatchuKVM Terminal',
|
||||
serial: 'Serial Port Terminal',
|
||||
serialPort: 'Serial Port',
|
||||
serialPortPlaceholder: 'Please enter the serial port',
|
||||
baudrate: 'Baud rate',
|
||||
parity: 'Parity',
|
||||
parityNone: 'None',
|
||||
parityEven: 'Even',
|
||||
parityOdd: 'Odd',
|
||||
flowControl: 'Flow control',
|
||||
flowControlNone: 'None',
|
||||
flowControlSoft: 'Soft',
|
||||
flowControlHard: 'Hard',
|
||||
dataBits: 'Data bits',
|
||||
stopBits: 'Stop bits',
|
||||
confirm: 'Ok'
|
||||
},
|
||||
wol: {
|
||||
title: 'Wake-on-LAN',
|
||||
sending: 'Sending command...',
|
||||
sent: 'Command sent',
|
||||
input: 'Please enter the MAC',
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Image Downloader',
|
||||
input: 'Please enter a remote image URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partition is RO, so we cannot download the image'
|
||||
},
|
||||
power: {
|
||||
title: 'Power',
|
||||
showConfirm: 'Confirmation',
|
||||
showConfirmTip: 'Power operations require an extra confirmation',
|
||||
reset: 'Reset',
|
||||
power: 'Power',
|
||||
powerShort: 'Power (short click)',
|
||||
powerLong: 'Power (long click)',
|
||||
resetConfirm: 'Proceed reset operation?',
|
||||
powerConfirm: 'Proceed power operation?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
about: {
|
||||
title: 'About BatchuKVM',
|
||||
information: 'Information',
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'Application Version',
|
||||
applicationTip: 'BatchuKVM web application version',
|
||||
image: 'Image Version',
|
||||
imageTip: 'BatchuKVM system image version',
|
||||
deviceKey: 'Device Key',
|
||||
community: 'Community',
|
||||
hostname: 'Hostname',
|
||||
hostnameUpdated: 'Hostname updated. Reboot to apply.',
|
||||
ipType: {
|
||||
Wired: 'Wired',
|
||||
Wireless: 'Wireless',
|
||||
Other: 'Other'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
display: 'Display',
|
||||
language: 'Language',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBarDesc: 'Display icons in the menu bar',
|
||||
webTitle: 'Web Title',
|
||||
webTitleDesc: 'Customize the web page title'
|
||||
},
|
||||
device: {
|
||||
title: 'Device',
|
||||
oled: {
|
||||
title: 'OLED',
|
||||
description: 'Turn off OLED screen after',
|
||||
0: 'Never',
|
||||
15: '15 sec',
|
||||
30: '30 sec',
|
||||
60: '1 min',
|
||||
180: '3 min',
|
||||
300: '5 min',
|
||||
600: '10 min',
|
||||
1800: '30 min',
|
||||
3600: '1 hour'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi',
|
||||
setBtn: 'Config'
|
||||
},
|
||||
ssh: {
|
||||
description: 'Enable SSH remote access',
|
||||
tip: 'Set a strong password before enabling (Account - Change Password)'
|
||||
},
|
||||
tls: {
|
||||
description: 'Enable HTTPS protocol',
|
||||
tip: 'Be aware: Using HTTPS can increase latency, especially with MJPEG video mode.'
|
||||
},
|
||||
advanced: 'Advanced Settings',
|
||||
swap: {
|
||||
title: 'Swap',
|
||||
disable: 'Disable',
|
||||
description: 'Set the swap file size',
|
||||
tip: "Enabling this feature could shorten your SD card's usable life!"
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: 'Mouse Jiggler',
|
||||
description: 'Prevent the remote host from sleeping',
|
||||
disable: 'Disable',
|
||||
absolute: 'Absolute Mode',
|
||||
relative: 'Relative Mode'
|
||||
},
|
||||
mdns: {
|
||||
description: 'Enable mDNS discovery service',
|
||||
tip: "Turning it off if it's not needed"
|
||||
},
|
||||
hdmi: {
|
||||
description: 'Enable HDMI/monitor output'
|
||||
},
|
||||
hidOnly: 'HID-Only Mode',
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
network: 'Virtual Network',
|
||||
networkDesc: 'Mount virtual network card on the remote host',
|
||||
reboot: 'Reboot',
|
||||
rebootDesc: 'Are you sure you want to reboot BatchuKVM?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
},
|
||||
tailscale: {
|
||||
title: 'Tailscale',
|
||||
memory: {
|
||||
title: 'Memory optimization',
|
||||
tip: 'When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. A Tailscale restart is required for the change to take effect.'
|
||||
},
|
||||
swap: {
|
||||
title: 'Swap memory',
|
||||
tip: 'If issues persist after enabling memory optimization, try enabling swap memory. This sets the swap file size to 256MB by default, which can be adjusted in "Settings > Device".'
|
||||
},
|
||||
restart: 'Restart Tailscale?',
|
||||
stop: 'Stop Tailscale?',
|
||||
stopDesc: 'Log out Tailscale and disable automatic startup on boot.',
|
||||
loading: 'Loading...',
|
||||
notInstall: 'Tailscale not found! Please install.',
|
||||
install: 'Install',
|
||||
installing: 'Installing',
|
||||
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 BatchuKVM directory /usr/bin/',
|
||||
upTailscaled: 'Upload tailscaled to BatchuKVM directory /usr/sbin/',
|
||||
refresh: 'Refresh current page',
|
||||
notRunning: 'Tailscale is not running. Please start it to continue.',
|
||||
run: 'Start',
|
||||
notLogin:
|
||||
'The device has not been bound yet. Please login and bind this device to your account.',
|
||||
urlPeriod: 'This url is valid for 10 minutes',
|
||||
login: 'Login',
|
||||
loginSuccess: 'Login Success',
|
||||
enable: 'Enable Tailscale',
|
||||
deviceName: 'Device Name',
|
||||
deviceIP: 'Device IP',
|
||||
account: 'Account',
|
||||
logout: 'Logout',
|
||||
logoutDesc: 'Are you sure you want to logout?',
|
||||
uninstall: 'Uninstall Tailscale',
|
||||
uninstallDesc: 'Are you sure you want to uninstall Tailscale?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
},
|
||||
update: {
|
||||
title: 'Check for Updates',
|
||||
queryFailed: 'Get version failed',
|
||||
updateFailed: 'Update failed. Please retry.',
|
||||
isLatest: 'You already have the latest version.',
|
||||
available: 'An update is available. Are you sure you want to update now?',
|
||||
updating: 'Update started. Please wait...',
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
preview: 'Preview Updates',
|
||||
previewDesc: 'Get early access to new features and improvements',
|
||||
previewTip:
|
||||
'Please be aware that preview releases may contain bugs or incomplete functionality!'
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
webAccount: 'Web Account Name',
|
||||
password: 'Password',
|
||||
updateBtn: 'Change',
|
||||
logoutBtn: 'Logout',
|
||||
logoutDesc: 'Are you sure you want to logout?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: "We've ran into an issue",
|
||||
refresh: 'Refresh'
|
||||
},
|
||||
fullscreen: {
|
||||
toggle: 'Toggle Fullscreen'
|
||||
},
|
||||
menu: {
|
||||
collapse: 'Collapse Menu',
|
||||
expand: 'Expand Menu'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default en;
|
||||
353
web/src/i18n/locales/ko.ts
Normal file
353
web/src/i18n/locales/ko.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
const ko = {
|
||||
translation: {
|
||||
head: {
|
||||
desktop: '원격 데스크톱',
|
||||
login: '로그인',
|
||||
changePassword: '비밀번호 변경',
|
||||
terminal: '터미널',
|
||||
wifi: 'Wi-Fi'
|
||||
},
|
||||
auth: {
|
||||
login: '로그인',
|
||||
placeholderUsername: '사용자 이름을 입력하세요.',
|
||||
placeholderPassword: '비밀번호를 입력하세요.',
|
||||
placeholderPassword2: '비밀번호를 다시 입력하세요.',
|
||||
noEmptyUsername: '사용자 이름은 비어있을 수 없습니다.',
|
||||
noEmptyPassword: '비밀번호는 비어있을 수 없습니다.',
|
||||
noAccount: '사용자 정보를 불러오는 데 실패했습니다. 페이지를 새로고침하거나 비밀번호를 초기화하세요.',
|
||||
invalidUser: '사용자 이름이나 비밀번호가 틀렸습니다.',
|
||||
error: '알 수 없는 오류',
|
||||
changePassword: '비밀번호 변경',
|
||||
changePasswordDesc: '보안을 위해 웹 로그인 비밀번호를 변경하세요.',
|
||||
differentPassword: '비밀번호가 서로 일치하지 않습니다.',
|
||||
illegalUsername: '사용자 이름에 사용할 수 없는 문자가 있습니다.',
|
||||
illegalPassword: '비밀번호에 사용할 수 없는 문자가 있습니다.',
|
||||
forgetPassword: '비밀번호 분실',
|
||||
ok: '확인',
|
||||
cancel: '취소',
|
||||
loginButtonText: '로그인',
|
||||
tips: {
|
||||
reset1:
|
||||
'비밀번호를 재설정하려면 BatchuKVM의 BOOT 버튼을 10초 동안 누르고 계세요.',
|
||||
reset2: '자세한 절차는 이 문서를 참조하세요:',
|
||||
reset3: '웹 기본 계정:',
|
||||
reset4: 'SSH 기본 계정:',
|
||||
change1: '이 작업을 수행하면 다음 비밀번호가 변경됩니다:',
|
||||
change2: '웹 로그인 비밀번호',
|
||||
change3: '시스템 루트 비밀번호 (SSH 로그인 비밀번호)',
|
||||
change4: '비밀번호를 재설정하려면 BatchuKVM의 BOOT 버튼을 길게 누르세요.'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'BatchuKVM Wi-Fi 설정',
|
||||
success: 'BatchuKVM의 네트워크 상태를 확인하고 새 IP 주소로 접속하세요.',
|
||||
failed: '작업에 실패했습니다. 다시 시도하세요.',
|
||||
confirmBtn: '확인',
|
||||
finishBtn: '완료'
|
||||
},
|
||||
screen: {
|
||||
title: '화면',
|
||||
video: '비디오 모드',
|
||||
videoDirectTips: '이 모드를 사용하려면 "설정 > 장치"에서 HTTPS를 활성화하세요',
|
||||
resolution: '해상도',
|
||||
auto: '자동 설정',
|
||||
autoTips:
|
||||
'일부 해상도에서는 화면이 왜곡되거나 마우스 동작이 비정상적으로 나타날 수 있습니다. 원격 컴퓨터의 해상도를 변경하거나 자동 설정 대신 수동 설정을 사용해 보세요.',
|
||||
fps: 'FPS',
|
||||
customizeFps: '사용자 지정',
|
||||
quality: '품질',
|
||||
qualityLossless: '무손실',
|
||||
qualityHigh: '높음',
|
||||
qualityMedium: '중간',
|
||||
qualityLow: '낮음',
|
||||
frameDetect: '프레임 탐지',
|
||||
frameDetectTip:
|
||||
'프레임 간의 차이를 계산합니다. 원격 호스트 화면에 변경 사항이 감지되지 않으면 비디오 스트림 전송을 중지합니다.',
|
||||
resetHdmi: 'HDMI 초기화'
|
||||
},
|
||||
keyboard: {
|
||||
title: '키보드',
|
||||
paste: '붙여넣기',
|
||||
tips: '표준 키보드 문자 및 기호만 지원됩니다',
|
||||
placeholder: '입력하세요',
|
||||
submit: '전송',
|
||||
virtual: '키보드',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
},
|
||||
mouse: {
|
||||
title: '마우스',
|
||||
cursor: '커서 스타일',
|
||||
default: '기본 커서',
|
||||
pointer: '포인터 커서',
|
||||
cell: '셀 커서',
|
||||
text: '텍스트 커서',
|
||||
grab: '잡기 커서',
|
||||
hide: '커서 숨기기',
|
||||
mode: '마우스 모드',
|
||||
absolute: '절대값 모드',
|
||||
relative: '상대값 모드',
|
||||
speed: '휠 속도',
|
||||
fast: '빠름',
|
||||
slow: '느림',
|
||||
requestPointer: '상대값 모드를 사용 중입니다. 커서를 찾으려면 데스크톱을 클릭하세요.',
|
||||
resetHid: 'HID 초기화',
|
||||
hidOnly: {
|
||||
title: 'HID 전용 모드',
|
||||
desc: '마우스와 키보드가 응답하지 않고 HID 초기화도 도움이 되지 않는다면, BatchuKVM과 장치 간의 호환성 문제일 수 있습니다. 더 나은 호환성을 위해 HID 전용 모드를 활성화해 보세요.',
|
||||
tip1: 'HID 전용 모드를 활성화하면 가상 USB와 가상 네트워크가 언마운트됩니다',
|
||||
tip2: 'HID 전용 모드에서는 이미지 마운트가 비활성화됩니다',
|
||||
tip3: '모드 전환 후 BatchuKVM이 자동으로 재부팅됩니다',
|
||||
enable: 'HID 전용 모드 활성화',
|
||||
disable: 'HID 전용 모드 비활성화'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: '이미지',
|
||||
loading: '불러오는 중...',
|
||||
empty: '아무것도 없습니다.',
|
||||
cdrom: 'CD-ROM 모드로 이미지 마운트',
|
||||
mountFailed: '이미지 마운트 실패',
|
||||
mountDesc:
|
||||
'일부 시스템에서는 이미지를 마운트하기 전에 원격 호스트에서 가상 디스크를 제거해야 합니다.',
|
||||
refresh: '이미지 목록 새로고침',
|
||||
tips: {
|
||||
title: '업로드 방법',
|
||||
usb1: 'USB를 통해 BatchuKVM을 컴퓨터에 연결하세요.',
|
||||
usb2: '가상 디스크가 마운트되었는지 확인하세요. (설정 - 가상 디스크).',
|
||||
usb3: '컴퓨터에서 가상 디스크를 열고 이미지 파일을 가상 디스크의 루트 디렉토리로 복사하세요.',
|
||||
scp1: 'BatchuKVM과 컴퓨터가 동일한 로컬 네트워크에 있는지 확인하세요.',
|
||||
scp2: '컴퓨터에서 터미널을 열고 SCP 명령을 사용하여 이미지 파일을 BatchuKVM의 /data 디렉터리에 업로드하세요.',
|
||||
scp3: '예시: scp [이미지 파일 경로] root@[BatchuKVM IP 주소]:/data',
|
||||
tfCard: 'TF 카드',
|
||||
tf1: '이 방법은 Linux 시스템에서 지원됩니다',
|
||||
tf2: 'BatchuKVM에서 TF 카드를 가져옵니다(전체 버전의 경우 먼저 케이스를 분해하세요).',
|
||||
tf3: 'TF 카드를 카드 리더기에 삽입하고 컴퓨터에 연결하세요.',
|
||||
tf4: '이미지 파일을 TF 카드의 /data 디렉터리에 복사하세요.',
|
||||
tf5: 'TF 카드를 BatchuKVM에 삽입하세요.'
|
||||
}
|
||||
},
|
||||
script: {
|
||||
title: '스크립트',
|
||||
upload: '업로드',
|
||||
run: '실행',
|
||||
runBackground: '백그라운드에서 실행',
|
||||
runFailed: '실행 실패',
|
||||
attention: '주의',
|
||||
delDesc: '이 파일을 정말로 삭제합니까?',
|
||||
confirm: '네',
|
||||
cancel: '아니오',
|
||||
delete: '삭제',
|
||||
close: '닫기'
|
||||
},
|
||||
terminal: {
|
||||
title: '터미널',
|
||||
BatchuKVM: 'BatchuKVM 터미널',
|
||||
serial: '시리얼 포트 터미널',
|
||||
serialPort: '시리얼 포트',
|
||||
serialPortPlaceholder: '시리얼 포트를 입력하세요',
|
||||
baudrate: '전송 속도',
|
||||
parity: '패리티',
|
||||
parityNone: '없음',
|
||||
parityEven: '짝수',
|
||||
parityOdd: '홀수',
|
||||
flowControl: '흐름 제어',
|
||||
flowControlNone: '없음',
|
||||
flowControlSoft: '소프트웨어',
|
||||
flowControlHard: '하드웨어',
|
||||
dataBits: '데이터 비트',
|
||||
stopBits: '정지 비트',
|
||||
confirm: '확인'
|
||||
},
|
||||
wol: {
|
||||
title: 'Wake-on-LAN',
|
||||
sending: '패킷 전송 중...',
|
||||
sent: '패킷 전송 완료',
|
||||
input: 'MAC주소를 입력하세요.',
|
||||
ok: '확인'
|
||||
},
|
||||
download: {
|
||||
title: '이미지 다운로드',
|
||||
input: '원격 이미지 URL을 입력하세요.',
|
||||
ok: '확인',
|
||||
disabled: '/data 파티션이 읽기 전용(RO) 상태이므로 이미지를 다운로드할 수 없습니다.'
|
||||
},
|
||||
power: {
|
||||
title: '전원',
|
||||
showConfirm: '확인',
|
||||
showConfirmTip: '전원 작업에는 추가 확인이 필요합니다',
|
||||
reset: '리셋',
|
||||
power: '전원',
|
||||
powerShort: '전원 (짧게 누르기)',
|
||||
powerLong: '전원 (길게 누르기)',
|
||||
resetConfirm: '리셋 작업을 진행하시겠습니까?',
|
||||
powerConfirm: '전원 작업을 진행하시겠습니까?',
|
||||
okBtn: '네',
|
||||
cancelBtn: '아니오'
|
||||
},
|
||||
settings: {
|
||||
title: '설정',
|
||||
about: {
|
||||
title: 'BatchuKVM 정보',
|
||||
information: '정보',
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: '펌웨어 버전',
|
||||
applicationTip: 'BatchuKVM 웹 애플리케이션 버전',
|
||||
image: '이미지 버전',
|
||||
imageTip: 'BatchuKVM 시스템 이미지 버전',
|
||||
deviceKey: '장치 키',
|
||||
community: '커뮤니티',
|
||||
hostname: '호스트 이름',
|
||||
hostnameUpdated: '호스트 이름이 업데이트되었습니다. 적용하려면 재부팅하세요.',
|
||||
ipType: {
|
||||
Wired: '유선',
|
||||
Wireless: '무선',
|
||||
Other: '기타'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: '디자인',
|
||||
display: '표시',
|
||||
language: '언어',
|
||||
menuBar: '메뉴 바',
|
||||
menuBarDesc: '메뉴 바에 아이콘을 표시',
|
||||
webTitle: '웹 제목',
|
||||
webTitleDesc: '웹 페이지 제목 사용자 지정'
|
||||
},
|
||||
device: {
|
||||
title: '장치',
|
||||
oled: {
|
||||
title: 'OLED',
|
||||
description: 'OLED 화면 자동 절전',
|
||||
0: '사용 안 함',
|
||||
15: '15초',
|
||||
30: '30초',
|
||||
60: '1분',
|
||||
180: '3분',
|
||||
300: '5분',
|
||||
600: '10분',
|
||||
1800: '30분',
|
||||
3600: '1시간'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Wi-Fi 설정',
|
||||
setBtn: '설정'
|
||||
},
|
||||
ssh: {
|
||||
description: 'SSH 원격 접속 활성화',
|
||||
tip: '활성화하기 전에 강력한 비밀번호를 설정하세요. (계정 - 비밀번호 변경)'
|
||||
},
|
||||
tls: {
|
||||
description: 'HTTPS 프로토콜 활성화',
|
||||
tip: '주의: HTTPS 사용 시 특히 MJPEG 비디오 모드에서 지연 시간이 증가할 수 있습니다.'
|
||||
},
|
||||
advanced: '고급 설정',
|
||||
swap: {
|
||||
title: '스왑',
|
||||
disable: '비활성화',
|
||||
description: '스왑 파일 크기 설정',
|
||||
tip: '이 기능을 활성화하면 SD 카드의 수명이 단축될 수 있습니다!'
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: '마우스 흔들기',
|
||||
description: '원격 호스트가 절전 모드로 진입하는 것을 방지',
|
||||
disable: '비활성화',
|
||||
absolute: '절대값 모드',
|
||||
relative: '상대값 모드'
|
||||
},
|
||||
mdns: {
|
||||
description: 'mDNS 검색 서비스 활성화',
|
||||
tip: '사용하지 않는 경우 끄는 것이 좋습니다'
|
||||
},
|
||||
hdmi: {
|
||||
description: 'HDMI/모니터 출력 활성화'
|
||||
},
|
||||
hidOnly: 'HID 전용 모드',
|
||||
disk: '가상 디스크',
|
||||
diskDesc: '원격 호스트에서 가상 USB를 마운트합니다.',
|
||||
network: '가상 네트워크',
|
||||
networkDesc: '원격 호스트에서 가상 네트워크 카드를 마운트합니다.',
|
||||
reboot: '재부팅',
|
||||
rebootDesc: 'BatchuKVM을 재부팅하시겠습니까?',
|
||||
okBtn: '네',
|
||||
cancelBtn: '아니오'
|
||||
},
|
||||
tailscale: {
|
||||
title: 'Tailscale',
|
||||
memory: {
|
||||
title: '메모리 최적화',
|
||||
tip: '메모리 사용량이 제한을 초과하면 가비지 컬렉션이 더 적극적으로 실행되어 메모리를 확보하려고 시도합니다. Tailscale을 사용할 경우 50MB로 설정하는 것이 좋습니다. 변경 사항을 적용하려면 Tailscale을 다시 시작해야 합니다.',
|
||||
disable: '비활성화'
|
||||
},
|
||||
restart: '정말로 Tailscale을 다시 시작하시겠습니까?',
|
||||
stop: '정말로 Tailscale을 중지하시겠습니까?',
|
||||
stopDesc: 'Tailscale에서 로그아웃하고 자동 시작을 비활성화합니다.',
|
||||
loading: '불러오는 중...',
|
||||
notInstall: 'Tailscale이 없습니다. 설치해주세요.',
|
||||
install: '설치',
|
||||
installing: '설치중',
|
||||
failed: '설치 실패',
|
||||
retry: '새로고침하고 다시 시도하거나, 수동으로 설치하세요',
|
||||
download: '다운로드 중 :',
|
||||
package: '패키지 설치',
|
||||
unzip: '압축 해제',
|
||||
upTailscale: 'tailscale을 BatchuKVM 의 다음 경로에 업로드 했습니다. : /usr/bin/',
|
||||
upTailscaled: 'tailscaled을 BatchuKVM 의 다음 경로에 업로드 했습니다. : /usr/sbin/',
|
||||
refresh: '현재 페이지 새로고침',
|
||||
notLogin:
|
||||
'이 기기는 현재 연동 되지 않았습니다. 로그인해서 계정에 이 장치를 연동하세요.',
|
||||
urlPeriod: '이 주소는 10분간 유효합니다.',
|
||||
login: '로그인',
|
||||
loginSuccess: '로그인 성공',
|
||||
enable: 'Tailscale 활성화',
|
||||
deviceName: '장치 이름',
|
||||
deviceIP: '장치 IP',
|
||||
account: '계정',
|
||||
logout: '로그아웃',
|
||||
logoutDesc: '정말로 로그아웃 하시겠습니까?',
|
||||
logout2: '정말로 로그아웃 합니까?',
|
||||
uninstall: 'Tailscale 제거',
|
||||
okBtn: '네',
|
||||
cancelBtn: '아니오'
|
||||
},
|
||||
update: {
|
||||
title: '업데이트 확인',
|
||||
queryFailed: '버전 확인 실패',
|
||||
updateFailed: '업데이트 실패, 재시도하세요.',
|
||||
isLatest: '이미 최신 버전입니다.',
|
||||
available: '업데이트가 가능합니다. 정말로 업데이트 할까요?',
|
||||
updating: '업데이트 시작. 잠시 기다려주세요...',
|
||||
confirm: '확인',
|
||||
cancel: '취소',
|
||||
preview: '미리보기 업데이트',
|
||||
previewDesc: '새로운 기능과 개선 사항에 미리 접근하세요',
|
||||
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!'
|
||||
},
|
||||
account: {
|
||||
title: '계정',
|
||||
webAccount: '웹 계정',
|
||||
password: '비밀번호',
|
||||
updateBtn: '업데이트',
|
||||
logoutBtn: '로그아웃',
|
||||
logoutDesc: '정말로 로그아웃 하시겠습니까?',
|
||||
okBtn: '네',
|
||||
cancelBtn: '아니오'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: '문제가 발생했습니다.',
|
||||
refresh: '새로고침'
|
||||
},
|
||||
fullscreen: {
|
||||
toggle: '전체 화면 전환'
|
||||
},
|
||||
menu: {
|
||||
collapse: '메뉴 접기',
|
||||
expand: '메뉴 펼치기'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ko;
|
||||
7
web/src/jotai/keyboard.ts
Normal file
7
web/src/jotai/keyboard.ts
Normal 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);
|
||||
13
web/src/jotai/mouse.ts
Normal file
13
web/src/jotai/mouse.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
// mouse cursor style
|
||||
export const mouseStyleAtom = atom('cursor-default');
|
||||
|
||||
// mouse mode: absolute or relative
|
||||
export const mouseModeAtom = atom('absolute');
|
||||
|
||||
// mouse scroll interval (unit: ms)
|
||||
export const scrollIntervalAtom = atom(0);
|
||||
|
||||
// hid mode: normal or hid-only
|
||||
export const hidModeAtom = atom<'normal' | 'hid-only'>('normal');
|
||||
14
web/src/jotai/screen.ts
Normal file
14
web/src/jotai/screen.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
import { Resolution } from '@/types';
|
||||
|
||||
export const isHdmiEnabledAtom = atom(true);
|
||||
|
||||
// video mode
|
||||
// direct: stream H.264 over HTTP
|
||||
// h264: stream H.264 over WebRTC
|
||||
// mjpeg: stream JPEG over HTTP
|
||||
export const videoModeAtom = atom('');
|
||||
|
||||
// browser screen resolution
|
||||
export const resolutionAtom = atom<Resolution | null>(null);
|
||||
7
web/src/jotai/settings.ts
Normal file
7
web/src/jotai/settings.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
// menu bar disabled items
|
||||
export const menuDisabledItemsAtom = atom<string[]>([]);
|
||||
|
||||
// web title
|
||||
export const webTitleAtom = atom('');
|
||||
23
web/src/lib/cookie.ts
Normal file
23
web/src/lib/cookie.ts
Normal 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);
|
||||
}
|
||||
9
web/src/lib/encrypt.ts
Normal file
9
web/src/lib/encrypt.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
// This key is only used to prevent the data from being transmitted in plaintext.
|
||||
const SECRET_KEY = 'NanoKVM-KOREA-TestKey-2512092155';
|
||||
|
||||
export function encrypt(data: string) {
|
||||
const dataEncrypt = CryptoJS.AES.encrypt(data, SECRET_KEY).toString();
|
||||
return encodeURIComponent(dataEncrypt);
|
||||
}
|
||||
74
web/src/lib/http.ts
Normal file
74
web/src/lib/http.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { removeToken } from '@/lib/cookie.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
|
||||
type Response = {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: any;
|
||||
};
|
||||
|
||||
class Http {
|
||||
private instance: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
const baseURL = getBaseUrl('http');
|
||||
const withCredentials = (import.meta.env.VITE_WITH_CREDENTIALS as string) !== 'false';
|
||||
|
||||
this.instance = axios.create({
|
||||
baseURL,
|
||||
withCredentials,
|
||||
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 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();
|
||||
196
web/src/lib/localstorage.ts
Normal file
196
web/src/lib/localstorage.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Resolution } from '@/types';
|
||||
|
||||
const LANGUAGE_KEY = 'nano-kvm-language';
|
||||
const VIDEO_MODE_KEY = 'nano-kvm-vide-mode';
|
||||
const WEB_RESOLUTION_KEY = 'nano-kvm-web-resolution';
|
||||
const FPS_KEY = 'nano-kvm-fps';
|
||||
const QUALITY_KEY = 'nano-kvm-quality';
|
||||
const GOP_KEY = 'nano-kvm-gop';
|
||||
const FRAME_DETECT_KEY = 'nano-kvm-frame-detect';
|
||||
const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style';
|
||||
const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode';
|
||||
const MOUSE_SCROLL_INTERVAL_KEY = 'nanokvm-kvm-mouse-scroll-interval';
|
||||
const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
|
||||
const KEYBOARD_SYSTEM_KEY = 'nano-kvm-keyboard-system';
|
||||
const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language';
|
||||
const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password';
|
||||
const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items';
|
||||
const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm';
|
||||
|
||||
type ItemWithExpiry = {
|
||||
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: ItemWithExpiry = {
|
||||
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: ItemWithExpiry = JSON.parse(itemStr);
|
||||
const now = new Date();
|
||||
if (now.getTime() > item.expiry) {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value;
|
||||
}
|
||||
|
||||
export function getLanguage() {
|
||||
return localStorage.getItem(LANGUAGE_KEY);
|
||||
}
|
||||
|
||||
export function setLanguage(language: string) {
|
||||
localStorage.setItem(LANGUAGE_KEY, language);
|
||||
}
|
||||
|
||||
export function getVideoMode() {
|
||||
return localStorage.getItem(VIDEO_MODE_KEY);
|
||||
}
|
||||
|
||||
export function setVideoMode(mode: string) {
|
||||
localStorage.setItem(VIDEO_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
export function getResolution(): Resolution | null {
|
||||
const resolution = localStorage.getItem(WEB_RESOLUTION_KEY);
|
||||
if (resolution) {
|
||||
const obj = JSON.parse(window.atob(resolution));
|
||||
return obj as Resolution;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setResolution(resolution: Resolution) {
|
||||
localStorage.setItem(WEB_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 getGop() {
|
||||
const gop = localStorage.getItem(GOP_KEY);
|
||||
return gop ? Number(gop) : null;
|
||||
}
|
||||
|
||||
export function setGop(gop: number) {
|
||||
localStorage.setItem(GOP_KEY, String(gop));
|
||||
}
|
||||
|
||||
export function getFrameDetect(): boolean {
|
||||
const enabled = localStorage.getItem(FRAME_DETECT_KEY);
|
||||
return enabled === 'true';
|
||||
}
|
||||
|
||||
export function setFrameDetect(enabled: boolean) {
|
||||
localStorage.setItem(FRAME_DETECT_KEY, String(enabled));
|
||||
}
|
||||
|
||||
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 getMouseScrollInterval() {
|
||||
const interval = localStorage.getItem(MOUSE_SCROLL_INTERVAL_KEY);
|
||||
return interval ? Number(interval) : null;
|
||||
}
|
||||
|
||||
export function setMouseScrollInterval(interval: number): void {
|
||||
localStorage.setItem(MOUSE_SCROLL_INTERVAL_KEY, String(interval));
|
||||
}
|
||||
|
||||
export function getSkipUpdate() {
|
||||
const skip = getWithExpiry(SKIP_UPDATE_KEY);
|
||||
return skip === 'true';
|
||||
}
|
||||
|
||||
export function setSkipUpdate(skip: boolean) {
|
||||
const expiry = 3 * 24 * 60 * 60 * 1000; // 3 days
|
||||
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry);
|
||||
}
|
||||
|
||||
export function setKeyboardSystem(system: string) {
|
||||
localStorage.setItem(KEYBOARD_SYSTEM_KEY, system);
|
||||
}
|
||||
|
||||
export function getKeyboardSystem() {
|
||||
return localStorage.getItem(KEYBOARD_SYSTEM_KEY);
|
||||
}
|
||||
|
||||
export function setKeyboardLanguage(language: string) {
|
||||
localStorage.setItem(KEYBOARD_LANGUAGE_KEY, language);
|
||||
}
|
||||
|
||||
export function getKeyboardLanguage() {
|
||||
return localStorage.getItem(KEYBOARD_LANGUAGE_KEY);
|
||||
}
|
||||
|
||||
export function setSkipModifyPassword(skip: boolean) {
|
||||
const expiry = 3 * 24 * 60 * 60 * 1000; // 3 days
|
||||
setWithExpiry(SKIP_MODIFY_PASSWORD_KEY, String(skip), expiry);
|
||||
}
|
||||
|
||||
export function getSkipModifyPassword() {
|
||||
const skip = getWithExpiry(SKIP_MODIFY_PASSWORD_KEY);
|
||||
return skip === 'true';
|
||||
}
|
||||
|
||||
export function setMenuDisabledItems(items: string[]) {
|
||||
const value = JSON.stringify(items);
|
||||
localStorage.setItem(MENU_DISABLED_ITEMS_KEY, value);
|
||||
}
|
||||
|
||||
export function getMenuDisabledItems(): string[] {
|
||||
const value = localStorage.getItem(MENU_DISABLED_ITEMS_KEY);
|
||||
return value ? JSON.parse(value) : [];
|
||||
}
|
||||
|
||||
export function getPowerConfirm() {
|
||||
const enabled = localStorage.getItem(POWER_CONFIRM_KEY);
|
||||
return enabled === 'true';
|
||||
}
|
||||
|
||||
export function setPowerConfirm(enabled: boolean) {
|
||||
localStorage.setItem(POWER_CONFIRM_KEY, String(enabled));
|
||||
}
|
||||
21
web/src/lib/service.ts
Normal file
21
web/src/lib/service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export function getHostname(): string {
|
||||
const ip = import.meta.env.VITE_SERVER_IP as string;
|
||||
return ip ? ip : window.location.hostname;
|
||||
}
|
||||
|
||||
export function getPort(): string {
|
||||
const port = import.meta.env.VITE_SERVER_PORT as string;
|
||||
return port ? port : window.location.port;
|
||||
}
|
||||
|
||||
export function getBaseUrl(type: 'http' | 'ws'): string {
|
||||
let protocol = window.location.protocol;
|
||||
if (type === 'ws') {
|
||||
protocol = protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
}
|
||||
|
||||
const hostname = getHostname();
|
||||
const port = getPort();
|
||||
|
||||
return `${protocol}//${hostname}:${port}`;
|
||||
}
|
||||
66
web/src/lib/websocket.ts
Normal file
66
web/src/lib/websocket.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { IMessageEvent, w3cwebsocket as W3cWebSocket } from 'websocket';
|
||||
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
|
||||
type Event = (message: IMessageEvent) => void;
|
||||
|
||||
const eventMap: Map<string, Event> = new Map<string, Event>();
|
||||
|
||||
class WsClient {
|
||||
private readonly url: string;
|
||||
private instance: W3cWebSocket;
|
||||
|
||||
constructor() {
|
||||
this.url = `${getBaseUrl('ws')}/api/ws`;
|
||||
this.instance = new W3cWebSocket(this.url);
|
||||
this.setEvents();
|
||||
}
|
||||
|
||||
public connect() {
|
||||
this.close();
|
||||
|
||||
this.instance = new W3cWebSocket(this.url);
|
||||
this.setEvents();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
public register(type: string, fn: (message: IMessageEvent) => void) {
|
||||
eventMap.set(type, fn);
|
||||
|
||||
this.setEvents();
|
||||
}
|
||||
|
||||
public unregister(type: string) {
|
||||
eventMap.delete(type);
|
||||
|
||||
this.setEvents();
|
||||
}
|
||||
|
||||
private setEvents() {
|
||||
this.instance.onmessage = (message) => {
|
||||
const data = JSON.parse(message.data as string);
|
||||
if (!data) return;
|
||||
|
||||
const fn = eventMap.get(data.type);
|
||||
if (!fn) return;
|
||||
|
||||
fn(message);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const client = new WsClient();
|
||||
53
web/src/main.tsx
Normal file
53
web/src/main.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
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';
|
||||
|
||||
const renderApp = () => {
|
||||
const themeConfig = {
|
||||
algorithm: theme.darkAlgorithm,
|
||||
components: {
|
||||
Collapse: {
|
||||
headerPadding: 0,
|
||||
contentPadding: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return 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={themeConfig}>
|
||||
<RouterProvider router={router} />
|
||||
</ConfigProvider>
|
||||
</HelmetProvider>
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</React.StrictMode>
|
||||
);
|
||||
};
|
||||
|
||||
if (import.meta.env.MODE === 'mocked') {
|
||||
const { worker } = await import('./mocks/browser');
|
||||
worker.start().then(() => {
|
||||
return renderApp();
|
||||
});
|
||||
}
|
||||
|
||||
renderApp();
|
||||
14
web/src/mocks/browser.ts
Normal file
14
web/src/mocks/browser.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { setupWorker } from 'msw/browser'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
|
||||
export const handlers = [
|
||||
http.post('/api/auth/login', () => {
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
data: {
|
||||
token: 'mocked_token',
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
export const worker = setupWorker(...handlers)
|
||||
118
web/src/pages/auth/login/index.tsx
Normal file
118
web/src/pages/auth/login/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState, ReactElement } 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 = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isLoading, setIsloading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (existToken()) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (msg) {
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
}
|
||||
}, [msg]);
|
||||
|
||||
function login(values: any) {
|
||||
if (isLoading) return;
|
||||
setIsloading(true);
|
||||
|
||||
const username = values.username;
|
||||
const password = encrypt(values.password);
|
||||
|
||||
api
|
||||
.login(username, password)
|
||||
.then((rsp: any) => {
|
||||
if (rsp.code !== 0) {
|
||||
setMsg(rsp.code === -2 ? t('auth.invalidUser') : t('auth.error'));
|
||||
return;
|
||||
}
|
||||
|
||||
setMsg('');
|
||||
setToken(rsp.data.token);
|
||||
|
||||
navigate('/', { replace: true });
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
setMsg(t('auth.error'));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsloading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={t('head.login')} />
|
||||
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<Form
|
||||
style={{ minWidth: 300, maxWidth: 500 }}
|
||||
initialValues={{ remember: true }}
|
||||
onFinish={login}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center pb-4">
|
||||
<img
|
||||
id="logo"
|
||||
src="/sipeed.ico"
|
||||
alt="Sipeed"
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
(evt.target as HTMLImageElement).classList.add('animate-spin');
|
||||
setTimeout(() => {
|
||||
(evt.target as HTMLImageElement).classList.remove('animate-spin');
|
||||
}, 1000);
|
||||
}} />
|
||||
</div>
|
||||
<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" loading={isLoading}>
|
||||
{t('auth.loginButtonText')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div className="flex justify-end pb-4 text-sm">
|
||||
<Tips />
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
68
web/src/pages/auth/login/tips.tsx
Normal file
68
web/src/pages/auth/login/tips.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, 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-300 underline underline-offset-4"
|
||||
onClick={showModal}
|
||||
>
|
||||
{t('auth.forgetPassword')}
|
||||
</span>
|
||||
|
||||
<Modal
|
||||
title={t('auth.forgetPassword')}
|
||||
open={isModalOpen}
|
||||
onCancel={hideModal}
|
||||
closeIcon={null}
|
||||
footer={null}
|
||||
centered={true}
|
||||
>
|
||||
<Card style={{ marginTop: '20px' }}>
|
||||
<div className="flex w-[430px] flex-col space-y-5">
|
||||
<div>{t('auth.tips.reset1')}</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{t('auth.tips.reset2')}</span>
|
||||
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/reset.html" target="_blank">
|
||||
wiki
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul className="list-outside list-disc">
|
||||
<li>
|
||||
{t('auth.tips.reset3')}
|
||||
<Text code={true}>admin/admin</Text>
|
||||
</li>
|
||||
<li>
|
||||
{t('auth.tips.reset4')}
|
||||
<Text code={true}>root/root</Text>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center pb-3 pt-10">
|
||||
<Button type="primary" className="w-24" onClick={hideModal}>
|
||||
{t('auth.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
132
web/src/pages/auth/password/index.tsx
Normal file
132
web/src/pages/auth/password/index.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, 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);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
window.location.replace('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={t('head.changePassword')} />
|
||||
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center space-y-5">
|
||||
<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={cancel}>
|
||||
{t('auth.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Card>
|
||||
<div className="flex w-[450px] flex-col">
|
||||
<div>{t('auth.tips.change1')}</div>
|
||||
<ul className="list-outside list-decimal">
|
||||
<li>{t('auth.tips.change2')}</li>
|
||||
<li>{t('auth.tips.change3')}</li>
|
||||
</ul>
|
||||
<div className="text-red-500">{t('auth.tips.change4')}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
77
web/src/pages/desktop/index.tsx
Normal file
77
web/src/pages/desktop/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { Head } from '@/components/head.tsx';
|
||||
|
||||
import { Keyboard } from './keyboard';
|
||||
import { VirtualKeyboard } from './keyboard/virtual-keyboard';
|
||||
import { Menu } from './menu';
|
||||
import { Mouse } from './mouse';
|
||||
import { Notification } from './notification.tsx';
|
||||
import { Screen } from './screen';
|
||||
|
||||
export const Desktop = () => {
|
||||
const { t } = useTranslation();
|
||||
const isBigScreen = useMediaQuery({ minWidth: 850 });
|
||||
|
||||
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
|
||||
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const mode = getVideoMode();
|
||||
setVideoMode(mode);
|
||||
|
||||
const res = storage.getResolution() || { width: 0, height: 0 };
|
||||
setResolution(res);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
client.send([0]);
|
||||
}, 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
client.unregister('stream');
|
||||
client.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function getVideoMode() {
|
||||
const defaultVideoMode = window.RTCPeerConnection ? 'h264' : 'mjpeg';
|
||||
|
||||
const cookieVideoMode = storage.getVideoMode();
|
||||
if (cookieVideoMode) {
|
||||
if (cookieVideoMode === 'direct' && !window.VideoDecoder) {
|
||||
return defaultVideoMode;
|
||||
}
|
||||
return cookieVideoMode;
|
||||
}
|
||||
|
||||
return defaultVideoMode;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={t('head.desktop')} />
|
||||
|
||||
{isBigScreen && <Notification />}
|
||||
|
||||
{videoMode && resolution && (
|
||||
<>
|
||||
<Menu />
|
||||
<Screen />
|
||||
<Mouse />
|
||||
{isKeyboardEnable && <Keyboard />}
|
||||
</>
|
||||
)}
|
||||
|
||||
<VirtualKeyboard />
|
||||
</>
|
||||
);
|
||||
};
|
||||
103
web/src/pages/desktop/keyboard/index.tsx
Normal file
103
web/src/pages/desktop/keyboard/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
|
||||
import { KeyboardCodes } from './mappings.ts';
|
||||
|
||||
export const Keyboard = () => {
|
||||
const pressedKeys = useRef<Set<string>>(new Set());
|
||||
|
||||
// listen keyboard events
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
window.addEventListener('blur', releaseAllKeys);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// press button
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
disableEvent(event);
|
||||
|
||||
if (!pressedKeys.current.has(event.code)) {
|
||||
pressedKeys.current.add(event.code);
|
||||
}
|
||||
|
||||
sendKeyDown(event);
|
||||
}
|
||||
|
||||
// release button
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
disableEvent(event);
|
||||
|
||||
if (pressedKeys.current.has(event.code)) {
|
||||
pressedKeys.current.delete(event.code);
|
||||
}
|
||||
|
||||
sendKeyUp();
|
||||
}
|
||||
|
||||
function releaseAllKeys() {
|
||||
if (pressedKeys.current.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendKeyUp();
|
||||
pressedKeys.current.clear();
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
releaseAllKeys();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
window.removeEventListener('blur', releaseAllKeys);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function sendKeyDown(event: KeyboardEvent) {
|
||||
const code = KeyboardCodes.get(event.code);
|
||||
if (!code) {
|
||||
console.log('unknown code: ', event.code);
|
||||
return;
|
||||
}
|
||||
|
||||
let ctrl = 0;
|
||||
if (event.ctrlKey) {
|
||||
if (pressedKeys.current.has('ControlLeft')) {
|
||||
ctrl = 1;
|
||||
} else if (pressedKeys.current.has('ControlRight')) {
|
||||
ctrl = 16;
|
||||
} else if (pressedKeys.current.has('AltRight')) {
|
||||
ctrl = 0;
|
||||
} else {
|
||||
ctrl = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const modifiers = [
|
||||
ctrl,
|
||||
event.shiftKey ? (pressedKeys.current.has('ShiftRight') ? 32 : 2) : 0,
|
||||
event.altKey ? (pressedKeys.current.has('AltRight') ? 64 : 4) : 0,
|
||||
event.metaKey ? (pressedKeys.current.has('MetaRight') ? 128 : 8) : 0
|
||||
];
|
||||
|
||||
client.send([1, code, ...modifiers]);
|
||||
}
|
||||
|
||||
function sendKeyUp() {
|
||||
client.send([1, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
// disable the default keyboard events
|
||||
function disableEvent(event: KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
168
web/src/pages/desktop/keyboard/mappings.ts
Normal file
168
web/src/pages/desktop/keyboard/mappings.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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],
|
||||
|
||||
['RusA', 4],
|
||||
['RusB', 5],
|
||||
['RusC', 6],
|
||||
['RusD', 7],
|
||||
['RusE', 8],
|
||||
['RusF', 9],
|
||||
['RusG', 10],
|
||||
['RusH', 11],
|
||||
['RusI', 12],
|
||||
['RusJ', 13],
|
||||
['RusK', 14],
|
||||
['RusL', 15],
|
||||
['RusM', 16],
|
||||
['RusN', 17],
|
||||
['RusO', 18],
|
||||
['RusP', 19],
|
||||
['RusQ', 20],
|
||||
['RusR', 21],
|
||||
['RusS', 22],
|
||||
['RusT', 23],
|
||||
['RusU', 24],
|
||||
['RusV', 25],
|
||||
['RusW', 26],
|
||||
['RusX', 27],
|
||||
['RusY', 28],
|
||||
['RusZ', 29],
|
||||
|
||||
['RusBracketLeft', 47],
|
||||
['RusBracketRight', 48],
|
||||
['RusBackslash', 49],
|
||||
['RusSemicolon', 51],
|
||||
['RusQuote', 52],
|
||||
['RusComma', 54],
|
||||
['RusPeriod', 55],
|
||||
['RusSlash', 56],
|
||||
|
||||
['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],
|
||||
['Backquote_azerty', 100],
|
||||
['IntlBackslash_qwertz', 100],
|
||||
|
||||
['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],
|
||||
|
||||
['Menu', 118],
|
||||
|
||||
['ControlLeft', 224],
|
||||
['ShiftLeft', 225],
|
||||
['AltLeft', 226],
|
||||
['MetaLeft', 227],
|
||||
['ControlRight', 228],
|
||||
['ShiftRight', 229],
|
||||
['AltRight', 230],
|
||||
['MetaRight', 231]
|
||||
]);
|
||||
|
||||
export const ModifierCodes: Map<string, number> = new Map([
|
||||
['ControlLeft', 1],
|
||||
['ShiftLeft', 2],
|
||||
['AltLeft', 4],
|
||||
['MetaLeft', 8],
|
||||
['ControlRight', 16],
|
||||
['ShiftRight', 32],
|
||||
['AltRight', 64],
|
||||
['MetaRight', 128]
|
||||
]);
|
||||
296
web/src/pages/desktop/keyboard/virtual-keyboard.tsx
Normal file
296
web/src/pages/desktop/keyboard/virtual-keyboard.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AppleOutlined, WindowsOutlined } from '@ant-design/icons';
|
||||
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 { ConfigProvider, Segmented, Select, theme } from 'antd';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
import { KeyboardCodes, ModifierCodes } from './mappings.ts';
|
||||
import {
|
||||
doubleKeys,
|
||||
keyboardArrowsOptions,
|
||||
keyboardControlPadOptions,
|
||||
keyboardOptions,
|
||||
modifierKeys,
|
||||
specialKeyMap
|
||||
} from './virtual-keys.ts';
|
||||
|
||||
export const VirtualKeyboard = () => {
|
||||
const isBigScreen = useMediaQuery({ minWidth: 850 });
|
||||
|
||||
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
|
||||
|
||||
const [keyboardLayout, setKeyboardLayout] = useState('default');
|
||||
const [keyboardSystem, setKeyboardSystem] = useState('win');
|
||||
const [keyboardLanguage, setKeyboardLanguage] = useState('en');
|
||||
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
|
||||
|
||||
const keyboardRef = useRef<any>(null);
|
||||
|
||||
const systems = [
|
||||
{ value: 'win', icon: <WindowsOutlined /> },
|
||||
{ value: 'mac', icon: <AppleOutlined /> }
|
||||
];
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'ru', label: 'Russian' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const system = storage.getKeyboardSystem();
|
||||
if (system && ['win', 'mac'].includes(system)) {
|
||||
setKeyboardSystem(system);
|
||||
}
|
||||
|
||||
const language = storage.getKeyboardLanguage();
|
||||
if (language && languages.some((lng) => lng.value === language)) {
|
||||
setKeyboardLanguage(language);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const layoutMap = new Map([
|
||||
['en', 'default'],
|
||||
['ru', 'rus'],
|
||||
['de', 'qwertz'],
|
||||
['fr', 'azerty']
|
||||
]);
|
||||
|
||||
if (keyboardLanguage === 'en' && keyboardSystem === 'mac') {
|
||||
setKeyboardLayout('mac');
|
||||
return;
|
||||
}
|
||||
|
||||
if (layoutMap.has(keyboardLanguage)) {
|
||||
setKeyboardLayout(layoutMap.get(keyboardLanguage)!);
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyboardLayout('default');
|
||||
}, [keyboardSystem, keyboardLanguage]);
|
||||
|
||||
function onKeyPress(key: string) {
|
||||
if (modifierKeys.includes(key)) {
|
||||
if (activeModifierKeys.includes(key)) {
|
||||
sendModifierKeyDown();
|
||||
sendModifierKeyUp();
|
||||
} else {
|
||||
setActiveModifierKeys([...activeModifierKeys, key]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sendKeydown(key);
|
||||
}
|
||||
|
||||
function onKeyReleased(key: string) {
|
||||
if (modifierKeys.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendKeyup();
|
||||
}
|
||||
|
||||
function sendKeydown(key: string) {
|
||||
const code = getKeyCode(key);
|
||||
if (!code) {
|
||||
console.log('unknown code: ', key);
|
||||
return;
|
||||
}
|
||||
|
||||
const modifiers = sendModifierKeyDown();
|
||||
|
||||
client.send([1, code, ...modifiers]);
|
||||
}
|
||||
|
||||
function getKeyCode(key: string) {
|
||||
// AZERTY: swap A↔Q and Z↔W on French physical positions
|
||||
if (keyboardLanguage === 'fr' && key.endsWith('_azerty')) {
|
||||
const base = key.replace('_azerty', '');
|
||||
if (base === 'KeyA') return KeyboardCodes.get('KeyQ');
|
||||
if (base === 'KeyQ') return KeyboardCodes.get('KeyA');
|
||||
if (base === 'KeyZ') return KeyboardCodes.get('KeyW');
|
||||
if (base === 'KeyW') return KeyboardCodes.get('KeyZ');
|
||||
// all other labels use their own code
|
||||
return KeyboardCodes.get(base);
|
||||
}
|
||||
if (keyboardLanguage === 'de' && key.endsWith('_qwertz')) {
|
||||
const base = key.replace('_qwertz', '');
|
||||
// Tausch
|
||||
if (base === 'KeyZ') return KeyboardCodes.get('KeyY');
|
||||
if (base === 'KeyY') return KeyboardCodes.get('KeyZ');
|
||||
|
||||
if (base === 'IntlBackslash') return KeyboardCodes.get('IntlBackslash_qwertz');
|
||||
|
||||
// all other labels use their own code
|
||||
return KeyboardCodes.get(base);
|
||||
}
|
||||
|
||||
const specialKey = specialKeyMap.get(key);
|
||||
if (specialKey) {
|
||||
return KeyboardCodes.get(specialKey);
|
||||
}
|
||||
|
||||
return KeyboardCodes.get(key);
|
||||
}
|
||||
|
||||
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 key = specialKeyMap.get(modifierKey)!;
|
||||
|
||||
const code = KeyboardCodes.get(key)!;
|
||||
const modifier = ModifierCodes.get(key)!;
|
||||
|
||||
if ([1, 16].includes(modifier)) {
|
||||
ctrl = modifier;
|
||||
} else if ([2, 32].includes(modifier)) {
|
||||
shift = modifier;
|
||||
} else if ([4, 64].includes(modifier)) {
|
||||
alt = modifier;
|
||||
} else if ([8, 128].includes(modifier)) {
|
||||
meta = modifier;
|
||||
}
|
||||
|
||||
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 selectSystem(system: string) {
|
||||
setKeyboardSystem(system);
|
||||
storage.setKeyboardSystem(system);
|
||||
}
|
||||
|
||||
function selectLanguage(language: string) {
|
||||
setKeyboardLanguage(language);
|
||||
storage.setKeyboardLanguage(language);
|
||||
}
|
||||
|
||||
function getButtonTheme(): KeyboardButtonTheme[] {
|
||||
const theme = [{ class: 'hg-double', buttons: doubleKeys.join(' ') }];
|
||||
|
||||
if (activeModifierKeys.length > 0) {
|
||||
const buttons = activeModifierKeys.join(' ');
|
||||
theme.push({ class: 'hg-highlight', buttons });
|
||||
}
|
||||
|
||||
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">
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-5">
|
||||
<Select
|
||||
size="small"
|
||||
style={{ minWidth: 90 }}
|
||||
defaultValue={keyboardLanguage}
|
||||
options={languages}
|
||||
onChange={selectLanguage}
|
||||
/>
|
||||
|
||||
{keyboardLanguage === 'en' && (
|
||||
<Segmented
|
||||
size="small"
|
||||
options={systems}
|
||||
value={keyboardSystem}
|
||||
onChange={selectSystem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
|
||||
<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}
|
||||
layoutName={keyboardLayout}
|
||||
{...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>
|
||||
);
|
||||
};
|
||||
369
web/src/pages/desktop/keyboard/virtual-keys.ts
Normal file
369
web/src/pages/desktop/keyboard/virtual-keys.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
// TODO: refactor it
|
||||
|
||||
// main keys
|
||||
export const keyboardOptions = {
|
||||
theme: 'simple-keyboard hg-theme-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} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||
],
|
||||
mac: [
|
||||
'{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}'
|
||||
],
|
||||
rus: [
|
||||
'{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} RusQ RusW RusE RusR RusT RusY RusU RusI RusO RusP RusBracketLeft RusBracketRight RusBackslash',
|
||||
'{capslock} RusA RusS RusD RusF RusG RusH RusJ RusK RusL RusSemicolon RusQuote {enter}',
|
||||
'{shiftleft} RusZ RusX RusC RusV RusB RusN RusM RusComma RusPeriod RusSlash {shiftright}',
|
||||
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||
],
|
||||
qwertz: [
|
||||
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||
'Backquote_qwertz Digit1_qwertz Digit2_qwertz Digit3_qwertz Digit4_qwertz Digit5_qwertz Digit6_qwertz Digit7_qwertz Digit8_qwertz Digit9_qwertz Digit0_qwertz Minus_qwertz Equal_qwertz {backspace}',
|
||||
'{tab} KeyQ_qwertz KeyW KeyE_qwertz KeyR KeyT KeyZ_qwertz KeyU KeyI KeyO KeyP BracketLeft_qwertz BracketRight_qwertz',
|
||||
'{capslock} KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon_qwertz Quote_qwertz Backslash_qwertz {enter}',
|
||||
'{shiftleft} IntlBackslash_qwertz KeyY_qwertz KeyX KeyC KeyV KeyB KeyN KeyM Comma_qwertz Period_qwertz Slash_qwertz {shiftright}',
|
||||
'{controlleft_qwertz} {winleft} {altleft} {space} {altright_qwertz} {winright} {menu} {controlright_qwertz}'
|
||||
],
|
||||
azerty: [
|
||||
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||
'Backquote_azerty Digit1_azerty Digit2_azerty Digit3_azerty Digit4_azerty Digit5_azerty Digit6_azerty Digit7_azerty Digit8_azerty Digit9_azerty Digit0_azerty Minus_azerty Equal_azerty {backspace}',
|
||||
'{tab} KeyA_azerty KeyZ_azerty KeyE_azerty KeyR_azerty KeyT_azerty KeyY_azerty KeyU_azerty KeyI_azerty KeyO_azerty KeyP_azerty BracketLeft_azerty BracketRight_azerty Backslash_azerty',
|
||||
'{capslock} KeyQ_azerty KeyS_azerty KeyD_azerty KeyF_azerty KeyG_azerty KeyH_azerty KeyJ_azerty KeyK_azerty KeyL_azerty Semicolon_azerty Quote_azerty {enter}',
|
||||
'{shiftleft} KeyW_azerty KeyX_azerty KeyC_azerty KeyV_azerty KeyB_azerty KeyN_azerty KeyM_azerty Comma_azerty Period_azerty Slash_azerty {shiftright}',
|
||||
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||
]
|
||||
},
|
||||
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',
|
||||
|
||||
Backquote_qwertz: '^<br/>°',
|
||||
Digit1_qwertz: '1<br/>!',
|
||||
Digit2_qwertz: '2<br/>"',
|
||||
Digit3_qwertz: '3<br/>§',
|
||||
Digit4_qwertz: '4<br/>$',
|
||||
Digit5_qwertz: '5<br/>%',
|
||||
Digit6_qwertz: '6<br/>&',
|
||||
Digit7_qwertz: '7{/',
|
||||
Digit8_qwertz: '8[(',
|
||||
Digit9_qwertz: '9])',
|
||||
Digit0_qwertz: '0}=',
|
||||
Minus_qwertz: 'ß\\?',
|
||||
Equal_qwertz: '´<br/>`',
|
||||
|
||||
KeyY_qwertz: 'Y',
|
||||
KeyZ_qwertz: 'Z',
|
||||
|
||||
KeyQ_qwertz: 'Q<br/>@',
|
||||
KeyE_qwertz: 'E<br/>€',
|
||||
BracketRight_qwertz: '+~*',
|
||||
Backslash_qwertz: '#<br/>\'',
|
||||
IntlBackslash_qwertz: '<|>',
|
||||
Comma_qwertz: ',<br/>;',
|
||||
Period_qwertz: '.<br/>:',
|
||||
Slash_qwertz: '-<br/>_',
|
||||
'{controlleft_qwertz}': 'STRG',
|
||||
'{altright_qwertz}': 'ALT GR',
|
||||
'{controlright_qwertz}': 'STRG',
|
||||
|
||||
BracketLeft_qwertz: 'Ü',
|
||||
Semicolon_qwertz: 'Ö',
|
||||
Quote_qwertz: 'Ä',
|
||||
|
||||
'{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',
|
||||
'{winleft}': 'Win',
|
||||
'{space}': 'Space',
|
||||
'{metaright}': 'Cmd',
|
||||
'{winright}': 'Win',
|
||||
'{altright}': 'Alt',
|
||||
'{menu}': 'Menu',
|
||||
'{controlright}': 'Ctrl',
|
||||
|
||||
RusQ: 'Й',
|
||||
RusW: 'Ц',
|
||||
RusE: 'У',
|
||||
RusR: 'К',
|
||||
RusT: 'Е',
|
||||
RusY: 'Н',
|
||||
RusU: 'Г',
|
||||
RusI: 'Ш',
|
||||
RusO: 'Щ',
|
||||
RusP: 'З',
|
||||
RusBracketLeft: 'Х',
|
||||
RusBracketRight: 'Ъ',
|
||||
RusBackslash: '/<br>\\',
|
||||
|
||||
RusA: 'Ф',
|
||||
RusS: 'Ы',
|
||||
RusD: 'В',
|
||||
RusF: 'А',
|
||||
RusG: 'П',
|
||||
RusH: 'Р',
|
||||
RusJ: 'О',
|
||||
RusK: 'Л',
|
||||
RusL: 'Д',
|
||||
RusSemicolon: 'Ж',
|
||||
RusQuote: 'Э',
|
||||
|
||||
RusZ: 'Я',
|
||||
RusX: 'Ч',
|
||||
RusC: 'С',
|
||||
RusV: 'М',
|
||||
RusB: 'И',
|
||||
RusN: 'Т',
|
||||
RusM: 'Ь',
|
||||
RusComma: 'Б',
|
||||
RusPeriod: 'Ю',
|
||||
RusSlash: ',<br/>.',
|
||||
|
||||
// AZERTY specific display keys
|
||||
// Row 1
|
||||
Backquote_azerty: '<<br/>>',
|
||||
Digit1_azerty: '&<br/>1',
|
||||
Digit2_azerty: 'é<br/>2',
|
||||
Digit3_azerty: '"<br/>#',
|
||||
Digit4_azerty: "'<br/>{",
|
||||
Digit5_azerty: '(<br/>[',
|
||||
Digit6_azerty: '-<br/>|',
|
||||
Digit7_azerty: 'è<br/>`',
|
||||
Digit8_azerty: '_<br/>\\',
|
||||
Digit9_azerty: 'ç<br/>^',
|
||||
Digit0_azerty: 'à<br/>@',
|
||||
Minus_azerty: ')<br/>]',
|
||||
Equal_azerty: '=<br/>}',
|
||||
|
||||
// Row 2
|
||||
KeyA_azerty: 'A',
|
||||
KeyZ_azerty: 'Z',
|
||||
KeyE_azerty: 'E<br/>€',
|
||||
KeyR_azerty: 'R',
|
||||
KeyT_azerty: 'T',
|
||||
KeyY_azerty: 'Y',
|
||||
KeyU_azerty: 'U',
|
||||
KeyI_azerty: 'I',
|
||||
KeyO_azerty: 'O',
|
||||
KeyP_azerty: 'P',
|
||||
BracketLeft_azerty: '¨<br/>^',
|
||||
BracketRight_azerty: '£<br/>$',
|
||||
Backslash_azerty: 'µ<br/>*',
|
||||
|
||||
// Row 3
|
||||
KeyQ_azerty: 'Q',
|
||||
KeyS_azerty: 'S',
|
||||
KeyD_azerty: 'D',
|
||||
KeyF_azerty: 'F',
|
||||
KeyG_azerty: 'G',
|
||||
KeyH_azerty: 'H',
|
||||
KeyJ_azerty: 'J',
|
||||
KeyK_azerty: 'K',
|
||||
KeyL_azerty: 'L',
|
||||
Semicolon_azerty: 'M',
|
||||
Quote_azerty: '%<br/>ù',
|
||||
|
||||
// Row 4
|
||||
KeyW_azerty: 'W',
|
||||
KeyX_azerty: 'X',
|
||||
KeyC_azerty: 'C',
|
||||
KeyV_azerty: 'V',
|
||||
KeyB_azerty: 'B',
|
||||
KeyN_azerty: 'N',
|
||||
KeyM_azerty: '?<br/>,',
|
||||
Comma_azerty: '.<br/>;',
|
||||
Period_azerty: '/<br/>:',
|
||||
Slash_azerty: '§<br/>!'
|
||||
},
|
||||
// Enable layout-specific display
|
||||
mergeDisplay: true,
|
||||
layoutCandidates: {
|
||||
default: 'default',
|
||||
shift: 'shift',
|
||||
azerty: 'azerty'
|
||||
}
|
||||
// ...remaining options...
|
||||
};
|
||||
|
||||
// control keys
|
||||
export const keyboardControlPadOptions = {
|
||||
theme: 'simple-keyboard hg-theme-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',
|
||||
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'],
|
||||
['{controlright}', 'ControlRight'],
|
||||
['{altleft}', 'AltLeft'],
|
||||
['{metaleft}', 'MetaLeft'],
|
||||
['{winleft}', 'MetaLeft'],
|
||||
['{space}', 'Space'],
|
||||
['{metaright}', 'MetaRight'],
|
||||
['{winright}', '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'],
|
||||
|
||||
//DE
|
||||
['{controlleft_qwertz}', 'ControlLeft'],
|
||||
['{controlright_qwertz}', 'ControlRight'],
|
||||
['{altright_qwertz}', 'AltRight']
|
||||
]);
|
||||
|
||||
// modifier keys
|
||||
export const modifierKeys = [
|
||||
'{shiftleft}',
|
||||
'{controlleft}',
|
||||
'{altleft}',
|
||||
'{metaleft}',
|
||||
'{winleft}',
|
||||
'{shiftright}',
|
||||
'{controlright}',
|
||||
'{altright}',
|
||||
'{metaright}',
|
||||
'{winright}',
|
||||
|
||||
// DE-spezifische Modifier-Tokens (für qwertz-Layout)
|
||||
'{controlleft_qwertz}',
|
||||
'{controlright_qwertz}',
|
||||
'{altright_qwertz}'
|
||||
];
|
||||
|
||||
// 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'
|
||||
];
|
||||
166
web/src/pages/desktop/menu/download.tsx
Normal file
166
web/src/pages/desktop/menu/download.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Divider, Input } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { downloadImage, imageEnabled, statusImage } from '@/api/download.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
export const DownloadImage = () => {
|
||||
const { t } = useTranslation();
|
||||
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [log, setLog] = useState('');
|
||||
const [diskEnabled, setDiskEnabled] = useState(false);
|
||||
const [popoverKey, setPopoverKey] = useState(0);
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const intervalId = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
checkDiskEnabled();
|
||||
}, []);
|
||||
|
||||
function checkDiskEnabled() {
|
||||
imageEnabled()
|
||||
.then((res) => {
|
||||
setDiskEnabled(res.data.enabled);
|
||||
})
|
||||
.catch(() => {
|
||||
setDiskEnabled(false);
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
clearInterval(intervalId.current);
|
||||
checkDiskEnabled();
|
||||
getDownloadStatus();
|
||||
if (!intervalId.current) {
|
||||
intervalId.current = setInterval(getDownloadStatus, 2500);
|
||||
}
|
||||
setIsKeyboardEnable(false);
|
||||
setPopoverKey((prevKey) => prevKey + 1); // Force re-render
|
||||
} else {
|
||||
setInput('');
|
||||
setStatus('');
|
||||
setLog('');
|
||||
|
||||
setIsKeyboardEnable(true);
|
||||
clearInterval(intervalId.current);
|
||||
intervalId.current = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
setInput(e.target.value);
|
||||
}
|
||||
|
||||
function getDownloadStatus() {
|
||||
statusImage().then((rsp) => {
|
||||
if (rsp.data.status) {
|
||||
setStatus(rsp.data.status);
|
||||
if (rsp.data.status === 'in_progress') {
|
||||
// Check if rsp has a percentage value
|
||||
if (rsp.data.percentage) {
|
||||
setLog('Downloading (' + rsp.data.percentage + ')' + ': ' + rsp.data.file);
|
||||
} else {
|
||||
setLog('Downloading' + ': ' + rsp.data.file);
|
||||
}
|
||||
setInput(rsp.data.file);
|
||||
}
|
||||
if (rsp.data.status === 'failed') {
|
||||
setLog('Failed');
|
||||
clearInterval(intervalId.current);
|
||||
}
|
||||
if (rsp.data.status === 'idle') {
|
||||
setLog(''); // Clear the log
|
||||
clearInterval(intervalId.current);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function download(url?: string) {
|
||||
if (!url) return;
|
||||
|
||||
setStatus('in_progress');
|
||||
setLog('Downloading: ' + url);
|
||||
// start the getDownloadStatus to tick every 5 seconds
|
||||
|
||||
downloadImage(url)
|
||||
.then(() => {
|
||||
getDownloadStatus();
|
||||
// Start the interval to check the download status
|
||||
if (!intervalId.current) {
|
||||
intervalId.current = setInterval(getDownloadStatus, 2500);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
|
||||
setStatus('failed');
|
||||
setLog('Failed');
|
||||
});
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div key={popoverKey} className="min-w-[300px]">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-base font-bold text-neutral-300">{t('download.title')}</span>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '10px 0 10px 0' }} />
|
||||
|
||||
{!diskEnabled ? (
|
||||
<div className="text-red-500">{t('download.disabled')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pb-1 text-neutral-500">{t('download.input')}</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'in_progress'}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => download(input)}
|
||||
disabled={status === 'in_progress'}
|
||||
>
|
||||
{t('download.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>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
title={t('download.title')}
|
||||
icon={<DownloadIcon size={18} />}
|
||||
content={content}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
42
web/src/pages/desktop/menu/fullscreen/index.tsx
Normal file
42
web/src/pages/desktop/menu/fullscreen/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { MaximizeIcon, MinimizeIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const Fullscreen = () => {
|
||||
const { t } = useTranslation();
|
||||
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();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={t('fullscreen.toggle')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="hidden h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white sm:flex"
|
||||
onClick={handleFullscreen}
|
||||
>
|
||||
{isFullscreen ? <MinimizeIcon size={18} /> : <MaximizeIcon size={18} />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
249
web/src/pages/desktop/menu/image/images.tsx
Normal file
249
web/src/pages/desktop/menu/image/images.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Modal, notification, Typography } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
ArrowBigDownDashIcon,
|
||||
ArrowBigUpDashIcon,
|
||||
LoaderCircleIcon,
|
||||
PackageIcon,
|
||||
PackageSearchIcon,
|
||||
Trash2Icon
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/storage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
|
||||
type ImagesProps = {
|
||||
isOpen: boolean;
|
||||
cdrom: boolean;
|
||||
setIsMounted: (isMounted: boolean) => void;
|
||||
};
|
||||
|
||||
export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [notify, contextHolder] = notification.useNotification();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [mountingImage, setMountingImage] = useState('');
|
||||
const [mountedImage, setMountedImage] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState('');
|
||||
const [deletingImage, setDeletingImage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
getImages();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// get image list
|
||||
function getImages() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
api
|
||||
.getImages()
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = rsp.data?.files;
|
||||
|
||||
if (files?.length > 0) {
|
||||
setImages(files);
|
||||
getMountedImage();
|
||||
} else {
|
||||
setImages([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
// get mounted image
|
||||
function getMountedImage() {
|
||||
api.getMountedImage().then((rsp) => {
|
||||
if (rsp.code !== 0) return;
|
||||
|
||||
const file = rsp.data?.file;
|
||||
setMountedImage(file);
|
||||
setIsMounted(!!file);
|
||||
});
|
||||
}
|
||||
|
||||
// mount/unmount image
|
||||
function mountImage(image: string) {
|
||||
if (mountingImage) return;
|
||||
setMountingImage(image);
|
||||
|
||||
client.close();
|
||||
|
||||
const isMounted = mountedImage === image;
|
||||
const filename = isMounted ? '' : image;
|
||||
|
||||
api
|
||||
.mountImage(filename, cdrom)
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
openNotification(isMounted);
|
||||
return;
|
||||
}
|
||||
|
||||
setMountedImage(filename);
|
||||
setIsMounted(!!filename);
|
||||
})
|
||||
.finally(() => {
|
||||
setMountingImage('');
|
||||
client.connect();
|
||||
});
|
||||
}
|
||||
|
||||
// show delete image modal
|
||||
function showDeleteModal(e: any, image: string) {
|
||||
e.stopPropagation();
|
||||
|
||||
const isMounted = mountedImage === image;
|
||||
const isDeleting = deletingImage !== '';
|
||||
|
||||
if (isMounted || isDeleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedImage(image);
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
|
||||
// delete image
|
||||
function deleteImage() {
|
||||
if (!selectedImage || !!deletingImage) return;
|
||||
setDeletingImage(selectedImage);
|
||||
|
||||
setIsModalOpen(false);
|
||||
|
||||
api
|
||||
.deleteImage(selectedImage)
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
getImages();
|
||||
|
||||
setSelectedImage('');
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingImage('');
|
||||
});
|
||||
}
|
||||
|
||||
// show mount/unmount failed notification
|
||||
function openNotification(isMounted: boolean) {
|
||||
const message = isMounted ? 'image.unmountFailed' : 'image.mountFailed';
|
||||
const description = isMounted ? 'image.unmountDesc' : 'image.mountDesc';
|
||||
|
||||
notify.open({
|
||||
message: t(message),
|
||||
description: t(description),
|
||||
duration: 10
|
||||
});
|
||||
}
|
||||
|
||||
// loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 py-5 text-neutral-400">
|
||||
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||
<span className="text-sm">{t('image.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// empty image
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 py-5 text-neutral-500">
|
||||
<PackageSearchIcon size={18} />
|
||||
<span className="text-sm">{t('image.empty')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex max-h-[400px] flex-col overflow-y-auto pb-2">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image}
|
||||
className={clsx(
|
||||
'group flex cursor-pointer select-none items-center space-x-1 rounded px-1 py-2 hover:bg-neutral-700/70',
|
||||
mountedImage === image && 'text-blue-500'
|
||||
)}
|
||||
onClick={() => mountImage(image)}
|
||||
>
|
||||
<div className="flex h-[24px] w-[24px] items-center justify-center">
|
||||
{mountingImage === image ? (
|
||||
<LoaderCircleIcon className="animate-spin" size={18} />
|
||||
) : (
|
||||
<PackageIcon size={18} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 truncate">{image.replace(/^.*[\\/]/, '')}</div>
|
||||
|
||||
<div className="flex h-[24px] w-[24px] items-center justify-center rounded">
|
||||
{mountedImage === image ? (
|
||||
<ArrowBigDownDashIcon size={22} className="hidden text-red-500 group-hover:block" />
|
||||
) : (
|
||||
<ArrowBigUpDashIcon size={22} className="hidden text-blue-500 group-hover:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-[24px] w-[24px] items-center justify-center rounded hover:bg-neutral-500/50',
|
||||
mountedImage === image
|
||||
? 'cursor-not-allowed text-neutral-500'
|
||||
: 'text-neutral-300 hover:text-red-500'
|
||||
)}
|
||||
onClick={(e) => showDeleteModal(e, image)}
|
||||
>
|
||||
{deletingImage === image ? (
|
||||
<LoaderCircleIcon className="animate-spin text-red-500" size={16} />
|
||||
) : (
|
||||
<Trash2Icon size={16} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={t('image.attention')}
|
||||
open={isModalOpen}
|
||||
width={520}
|
||||
footer={null}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
>
|
||||
<div className="flex flex-col items-center pb-10">
|
||||
<p>{t('image.deleteConfirm')}</p>
|
||||
<Typography.Text code>{selectedImage}</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center space-x-3 pb-3">
|
||||
<Button type="primary" danger onClick={deleteImage}>
|
||||
{t('image.okBtn')}
|
||||
</Button>
|
||||
<Button onClick={() => setIsModalOpen(false)}>{t('image.cancelBtn')}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{contextHolder}
|
||||
</>
|
||||
);
|
||||
};
|
||||
87
web/src/pages/desktop/menu/image/index.tsx
Normal file
87
web/src/pages/desktop/menu/image/index.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Divider, Modal, Segmented } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { DiscIcon, HardDriveIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/storage.ts';
|
||||
|
||||
import { Images } from './images.tsx';
|
||||
import { Tips } from './tips.tsx';
|
||||
|
||||
export const Image = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [mode, setMode] = useState('mass-storage');
|
||||
|
||||
const modes = [
|
||||
{
|
||||
value: 'mass-storage',
|
||||
label: (
|
||||
<div className="flex items-center space-x-1">
|
||||
<HardDriveIcon size={16} />
|
||||
<span>Mass Storage</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'cd-rom',
|
||||
label: (
|
||||
<div className="flex items-center space-x-1">
|
||||
<DiscIcon size={16} />
|
||||
<span>CD ROM</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
api.getMountedImage().then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setIsMounted(!!rsp.data?.file);
|
||||
}
|
||||
});
|
||||
|
||||
api.getCdRom().then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setMode(rsp.data?.cdrom === 1 ? 'cd-rom' : 'mass-storage');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
|
||||
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
|
||||
)}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<DiscIcon size={18} />
|
||||
</div>
|
||||
|
||||
<Modal open={isModalOpen} footer={null} onCancel={() => setIsModalOpen(false)}>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-xl font-bold">{t('image.title')}</span>
|
||||
<Tips />
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '24px 0' }} />
|
||||
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{t('image.mountMode')}</span>
|
||||
<Segmented value={mode} options={modes} disabled={isMounted} onChange={setMode} />
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '24px 0 0 0' }} />
|
||||
|
||||
<Images isOpen={isModalOpen} cdrom={mode === 'cd-rom'} setIsMounted={setIsMounted} />
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
78
web/src/pages/desktop/menu/image/tips.tsx
Normal file
78
web/src/pages/desktop/menu/image/tips.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import type { CollapseProps } from 'antd';
|
||||
import { Collapse, Modal } from 'antd';
|
||||
import { CircleHelpIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const Tips = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const items: CollapseProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: 'USB',
|
||||
children: (
|
||||
<ul className="list-decimal">
|
||||
<li>{t('image.tips.usb1')}</li>
|
||||
<li>{t('image.tips.usb2')}</li>
|
||||
<li>{t('image.tips.usb3')}</li>
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: 'SCP',
|
||||
children: (
|
||||
<ul className="list-decimal">
|
||||
<li>{t('image.tips.scp1')}</li>
|
||||
<li>{t('image.tips.scp2')}</li>
|
||||
<li>{t('image.tips.scp3')}</li>
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('image.tips.tfCard'),
|
||||
children: (
|
||||
<>
|
||||
<p className="pl-5 text-sm text-neutral-400">{t('image.tips.tf1')}</p>
|
||||
<ul className="list-decimal">
|
||||
<li>{t('image.tips.tf2')}</li>
|
||||
<li>{t('image.tips.tf3')}</li>
|
||||
<li>{t('image.tips.tf4')}</li>
|
||||
<li>{t('image.tips.tf5')}</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-1 text-neutral-500 hover:text-blue-500"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<CircleHelpIcon size={16} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={t('image.tips.title')}
|
||||
open={isModalOpen}
|
||||
width={520}
|
||||
footer={null}
|
||||
centered
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
>
|
||||
<Collapse
|
||||
accordion
|
||||
items={items}
|
||||
bordered={false}
|
||||
defaultActiveKey={['1']}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
131
web/src/pages/desktop/menu/index.tsx
Normal file
131
web/src/pages/desktop/menu/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Divider, Tooltip } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { MenuIcon, XIcon } from 'lucide-react';
|
||||
import Draggable from 'react-draggable';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getMenuDisabledItems } from '@/lib/localstorage.ts';
|
||||
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
|
||||
|
||||
import { DownloadImage } from './download.tsx';
|
||||
import { Fullscreen } from './fullscreen';
|
||||
import { Image } from './image';
|
||||
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 { Terminal } from './terminal';
|
||||
import { Wol } from './wol';
|
||||
|
||||
export const Menu = () => {
|
||||
const { t } = useTranslation();
|
||||
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(true);
|
||||
const [bounds, setBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
|
||||
|
||||
const nodeRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// disabled menu items
|
||||
const items = getMenuDisabledItems();
|
||||
setMenuDisabledItems(items);
|
||||
|
||||
// react-draggable bounds
|
||||
const handleResize = () => {
|
||||
if (!nodeRef.current) return;
|
||||
|
||||
const elementRect = nodeRef.current.getBoundingClientRect();
|
||||
const width = (window.innerWidth - elementRect.width) / 2;
|
||||
|
||||
setBounds({
|
||||
left: -width,
|
||||
top: -10,
|
||||
right: width,
|
||||
bottom: window.innerHeight - elementRect.height - 10
|
||||
});
|
||||
};
|
||||
|
||||
handleResize();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
nodeRef={nodeRef}
|
||||
bounds={bounds}
|
||||
handle="strong"
|
||||
positionOffset={{ x: '-50%', y: '0%' }}
|
||||
>
|
||||
<div ref={nodeRef} className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2">
|
||||
<div className="sticky top-[10px] flex w-full justify-center">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-[36px] items-center rounded bg-neutral-800/80',
|
||||
isMenuOpen ? 'flex' : 'hidden'
|
||||
)}
|
||||
>
|
||||
<strong>
|
||||
<div className="hidden h-[30px] cursor-move select-none items-center px-3 sm:flex">
|
||||
<img src="/sipeed.ico" width={18} height={18} draggable={false} alt="sipeed" />
|
||||
</div>
|
||||
</strong>
|
||||
|
||||
<Screen />
|
||||
<Keyboard />
|
||||
<Mouse />
|
||||
<Divider type="vertical" />
|
||||
|
||||
{!menuDisabledItems.includes('image') && <Image />}
|
||||
{!menuDisabledItems.includes('download') && <DownloadImage />}
|
||||
{!menuDisabledItems.includes('script') && <Script />}
|
||||
{!menuDisabledItems.includes('terminal') && <Terminal />}
|
||||
{!menuDisabledItems.includes('wol') && <Wol />}
|
||||
|
||||
{['image', 'script', 'terminal', 'wol', 'download'].some(
|
||||
(key) => !menuDisabledItems.includes(key)
|
||||
) && <Divider type="vertical" />}
|
||||
|
||||
{!menuDisabledItems.includes('power') && (
|
||||
<>
|
||||
<Power />
|
||||
<Divider type="vertical" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Settings />
|
||||
<Fullscreen />
|
||||
|
||||
<Tooltip title={t('menu.collapse')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="mr-1 flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white"
|
||||
onClick={() => setIsMenuOpen((o) => !o)}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMenuOpen && (
|
||||
<Tooltip title={t('menu.expand')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="flex h-[30px] w-[32px] cursor-pointer items-center justify-center rounded bg-neutral-800/50 text-white/50 hover:bg-neutral-700 hover:text-white"
|
||||
onClick={() => setIsMenuOpen((o) => !o)}
|
||||
>
|
||||
<MenuIcon size={20} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
31
web/src/pages/desktop/menu/keyboard/ctrl-alt-del.tsx
Normal file
31
web/src/pages/desktop/menu/keyboard/ctrl-alt-del.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OctagonMinus } from 'lucide-react';
|
||||
|
||||
import { KeyboardCodes, ModifierCodes } from '@/pages/desktop/keyboard/mappings.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
|
||||
export const CtrlAltDel = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function sendCtrlAltDel() {
|
||||
const ctrl = ModifierCodes.get('ControlLeft')!;
|
||||
const alt = ModifierCodes.get('AltLeft')!;
|
||||
const del = KeyboardCodes.get('Delete')!;
|
||||
|
||||
client.send([1, del, ctrl, 0, alt, 0]);
|
||||
client.send([1, 0, 0, 0, 0, 0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70"
|
||||
)}
|
||||
onClick={sendCtrlAltDel}
|
||||
>
|
||||
<OctagonMinus size={18} />
|
||||
<span>{t('keyboard.ctrlaltdel')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
web/src/pages/desktop/menu/keyboard/index.tsx
Normal file
26
web/src/pages/desktop/menu/keyboard/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { KeyboardIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { CtrlAltDel } from './ctrl-alt-del.tsx';
|
||||
import { Paste } from './paste.tsx';
|
||||
import { VirtualKeyboard } from './virtual-keyboard.tsx';
|
||||
|
||||
export const Keyboard = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
title={t('keyboard.title')}
|
||||
icon={<KeyboardIcon size={18} />}
|
||||
content={
|
||||
<>
|
||||
<Paste />
|
||||
<VirtualKeyboard />
|
||||
<CtrlAltDel />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
184
web/src/pages/desktop/menu/keyboard/paste.tsx
Normal file
184
web/src/pages/desktop/menu/keyboard/paste.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Divider, Input, Modal, Select } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { ClipboardIcon, ClipboardPasteIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { paste } from '@/api/hid';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
type InputStatus = '' | 'error';
|
||||
|
||||
export const Paste = () => {
|
||||
const { t } = useTranslation();
|
||||
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isClipboardSupported, setIsClipboardSupported] = useState(false);
|
||||
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
|
||||
const [langue, setLangue] = useState('en');
|
||||
const [status, setStatus] = useState<InputStatus>('');
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', label: t('keyboard.dropdownEnglish') },
|
||||
{ value: 'de', label: t('keyboard.dropdownGerman') }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setIsClipboardSupported('clipboard' in navigator);
|
||||
}, []);
|
||||
|
||||
function onChange(e: ChangeEvent<HTMLTextAreaElement>) {
|
||||
const value = e.target.value;
|
||||
setStatus(isASCII(value) ? '' : 'error');
|
||||
setInputValue(value);
|
||||
}
|
||||
|
||||
async function readFromClipboard() {
|
||||
if (isReadingClipboard) return;
|
||||
setIsReadingClipboard(true);
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
setInputValue((value) => value + text);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'NotAllowedError') {
|
||||
setErrMsg(t('keyboard.clipboardPermissionDenied'));
|
||||
return;
|
||||
}
|
||||
setErrMsg(t('keyboard.clipboardReadError'));
|
||||
} finally {
|
||||
setIsReadingClipboard(false);
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (isLoading || !inputValue) return;
|
||||
setIsLoading(true);
|
||||
|
||||
paste(inputValue, langue)
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
setErrMsg(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
setStatus('');
|
||||
setErrMsg('');
|
||||
setIsModalOpen(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function afterOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
setIsKeyboardEnable(!open);
|
||||
}
|
||||
|
||||
function isASCII(value: string) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) > 127) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70'
|
||||
)}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<ClipboardIcon size={18} />
|
||||
<span>{t('keyboard.paste')}</span>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
centered={false}
|
||||
footer={null}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
afterOpenChange={afterOpenChange}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl">{t('keyboard.paste')}</span>
|
||||
<span className="text-sm text-neutral-600">{t('keyboard.tips')}</span>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '14px 0' }} />
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex w-full items-center space-x-3 pb-2',
|
||||
isClipboardSupported ? 'justify-start' : 'justify-end'
|
||||
)}
|
||||
>
|
||||
{isClipboardSupported && (
|
||||
<Button
|
||||
color="default"
|
||||
variant="filled"
|
||||
icon={<ClipboardPasteIcon size={16} />}
|
||||
loading={isReadingClipboard}
|
||||
onClick={readFromClipboard}
|
||||
className="flex items-center"
|
||||
>
|
||||
{t('keyboard.readClipboard') || 'Read from Clipboard'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={langue}
|
||||
variant="filled"
|
||||
onChange={(value) => setLangue(value)}
|
||||
options={languages}
|
||||
></Select>
|
||||
</div>
|
||||
|
||||
<Input.TextArea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
status={status}
|
||||
showCount
|
||||
maxLength={1024}
|
||||
autoSize={{ minRows: 6, maxRows: 12 }}
|
||||
placeholder={t('keyboard.placeholder')}
|
||||
onFocus={() => setErrMsg('')}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
{errMsg && <div className="pt-1 text-sm text-red-500">{errMsg}</div>}
|
||||
|
||||
<div className="flex justify-center py-5">
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isLoading}
|
||||
htmlType="submit"
|
||||
style={{ width: '300px' }}
|
||||
onClick={submit}
|
||||
>
|
||||
{t('keyboard.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
23
web/src/pages/desktop/menu/keyboard/virtual-keyboard.tsx
Normal file
23
web/src/pages/desktop/menu/keyboard/virtual-keyboard.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import clsx from 'clsx';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { KeyboardIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
export const VirtualKeyboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const setIsKeyboardOpen = useSetAtom(isKeyboardOpenAtom);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70'
|
||||
)}
|
||||
onClick={() => setIsKeyboardOpen((o) => !o)}
|
||||
>
|
||||
<KeyboardIcon size={18} />
|
||||
<span>{t('keyboard.virtual')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
web/src/pages/desktop/menu/mouse/cursor.tsx
Normal file
54
web/src/pages/desktop/menu/mouse/cursor.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Popover } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { EyeOffIcon, HandIcon, MousePointerIcon, PlusIcon, TextCursorIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as ls from '@/lib/localstorage.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
export const Cursor = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
|
||||
|
||||
const mouseStyles = [
|
||||
{ name: t('mouse.default'), icon: <MousePointerIcon size={14} />, value: 'cursor-default' },
|
||||
{ name: t('mouse.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
|
||||
{ name: t('mouse.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
|
||||
{ name: t('mouse.text'), icon: <TextCursorIcon size={14} />, value: 'cursor-text' },
|
||||
{ name: t('mouse.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
|
||||
];
|
||||
|
||||
function updateMouseStyle(style: string) {
|
||||
setMouseStyle(style);
|
||||
ls.setMouseStyle(style);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{mouseStyles.map((style) => (
|
||||
<div
|
||||
key={style.value}
|
||||
className={clsx(
|
||||
'flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-3 pr-6 hover:bg-neutral-700/70',
|
||||
style.value === mouseStyle && 'text-green-500'
|
||||
)}
|
||||
onClick={() => updateMouseStyle(style.value)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
|
||||
<span>{style.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<MousePointerIcon size={18} />
|
||||
<span>{t('mouse.cursor')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
112
web/src/pages/desktop/menu/mouse/hid-mode.tsx
Normal file
112
web/src/pages/desktop/menu/mouse/hid-mode.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Divider, Modal, Typography } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { PenIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/hid.ts';
|
||||
import { hidModeAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
export const HidMode = () => {
|
||||
const { t } = useTranslation();
|
||||
const [hidMode, setHidMode] = useAtom(hidModeAtom);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getHidMode();
|
||||
}, []);
|
||||
|
||||
function getHidMode() {
|
||||
setIsLoading(true);
|
||||
|
||||
api
|
||||
.getHidMode()
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
setErrMsg(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setHidMode(rsp.data.mode);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function updateHidMode() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 30000);
|
||||
|
||||
const mode = hidMode === 'normal' ? 'hid-only' : 'normal';
|
||||
|
||||
api
|
||||
.setHidMode(mode)
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
setErrMsg(rsp.msg);
|
||||
setIsLoading(false);
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
setIsLoading(false);
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-[30px] cursor-pointer select-none items-center space-x-2 rounded px-3 hover:bg-neutral-700/70',
|
||||
hidMode === 'hid-only' ? 'text-blue-500' : 'text-neutral-300'
|
||||
)}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<PenIcon size={18} />
|
||||
<span>{t('mouse.hidOnly.title')}</span>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
title={t('mouse.hidOnly.title')}
|
||||
width={580}
|
||||
centered={false}
|
||||
footer={false}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
>
|
||||
<Divider />
|
||||
|
||||
<Paragraph>{t('mouse.hidOnly.desc')}</Paragraph>
|
||||
|
||||
<Paragraph type="secondary">
|
||||
<ul>
|
||||
<li>{t('mouse.hidOnly.tip1')}</li>
|
||||
<li>{t('mouse.hidOnly.tip2')}</li>
|
||||
<li>{t('mouse.hidOnly.tip3')}</li>
|
||||
</ul>
|
||||
</Paragraph>
|
||||
|
||||
{errMsg && <div className="pt-1 text-sm text-red-500">{errMsg}</div>}
|
||||
|
||||
<div className="flex justify-center pt-5">
|
||||
<Button danger type="primary" loading={isLoading} onClick={updateHidMode}>
|
||||
{hidMode === 'normal' ? t('mouse.hidOnly.enable') : t('mouse.hidOnly.disable')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
54
web/src/pages/desktop/menu/mouse/index.tsx
Normal file
54
web/src/pages/desktop/menu/mouse/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Divider } from 'antd';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { MouseIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as ls from '@/lib/localstorage';
|
||||
import { mouseModeAtom, mouseStyleAtom, scrollIntervalAtom } from '@/jotai/mouse';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { Cursor } from './cursor.tsx';
|
||||
import { HidMode } from './hid-mode.tsx';
|
||||
import { MouseMode } from './mouse-mode.tsx';
|
||||
import { ResetHid } from './reset-hid.tsx';
|
||||
import { Speed } from './speed.tsx';
|
||||
|
||||
export const Mouse = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setMouseStyle = useSetAtom(mouseStyleAtom);
|
||||
const setMouseMode = useSetAtom(mouseModeAtom);
|
||||
const setScrollInterval = useSetAtom(scrollIntervalAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const mouseStyle = ls.getMouseStyle();
|
||||
if (mouseStyle) {
|
||||
setMouseStyle(mouseStyle);
|
||||
}
|
||||
|
||||
const mouseMode = ls.getMouseMode();
|
||||
if (mouseMode) {
|
||||
setMouseMode(mouseMode);
|
||||
}
|
||||
|
||||
const interval = ls.getMouseScrollInterval();
|
||||
if (interval) {
|
||||
setScrollInterval(interval);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Cursor />
|
||||
<MouseMode />
|
||||
<Speed />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
|
||||
<HidMode />
|
||||
<ResetHid />
|
||||
</div>
|
||||
);
|
||||
|
||||
return <MenuItem title={t('mouse.title')} icon={<MouseIcon size={18} />} content={content} />;
|
||||
};
|
||||
57
web/src/pages/desktop/menu/mouse/mouse-mode.tsx
Normal file
57
web/src/pages/desktop/menu/mouse/mouse-mode.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Popover } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { CheckIcon, SquareDashedMousePointerIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as ls from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { mouseModeAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
export const MouseMode = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
|
||||
|
||||
const mouseModes = [
|
||||
{ name: t('mouse.absolute'), value: 'absolute' },
|
||||
{ name: t('mouse.relative'), value: 'relative' }
|
||||
];
|
||||
|
||||
function updateMouseMode(mode: string) {
|
||||
setMouseMode(mode);
|
||||
ls.setMouseMode(mode);
|
||||
|
||||
if (mode === 'relative') {
|
||||
client.close();
|
||||
setTimeout(() => {
|
||||
client.connect();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{mouseModes.map((mode) => (
|
||||
<div
|
||||
key={mode.value}
|
||||
className="flex cursor-pointer items-center space-x-1 rounded py-1.5 pl-2 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => updateMouseMode(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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<SquareDashedMousePointerIcon size={18} />
|
||||
<span>{t('mouse.mode')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
36
web/src/pages/desktop/menu/mouse/reset-hid.tsx
Normal file
36
web/src/pages/desktop/menu/mouse/reset-hid.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { RefreshCwIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/hid.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
|
||||
export const ResetHid = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
function resetHid() {
|
||||
if (isResetting) return;
|
||||
setIsResetting(true);
|
||||
|
||||
client.send([1, 0, 0, 0, 0, 0]);
|
||||
client.close();
|
||||
|
||||
api.reset().finally(() => {
|
||||
client.connect();
|
||||
setIsResetting(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[30px] cursor-pointer select-none items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70"
|
||||
onClick={resetHid}
|
||||
>
|
||||
<RefreshCwIcon className={clsx({ 'animate-spin text-blue-500': isResetting })} size={18} />
|
||||
<span>{t('mouse.resetHid')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
web/src/pages/desktop/menu/mouse/speed.tsx
Normal file
66
web/src/pages/desktop/menu/mouse/speed.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Popover, Slider } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { GaugeIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
const MAX_INTERVAL = 300;
|
||||
|
||||
export const Speed = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [scrollInterval, setScrollInterval] = useAtom(scrollIntervalAtom);
|
||||
|
||||
const [scrollSpeed, setScrollSpeed] = useState(100);
|
||||
|
||||
useEffect(() => {
|
||||
const speed = interval2Speed(scrollInterval);
|
||||
setScrollSpeed(speed);
|
||||
}, [scrollInterval]);
|
||||
|
||||
function update(speed: number): void {
|
||||
const interval = speed2Interval(speed);
|
||||
setScrollInterval(interval);
|
||||
storage.setMouseScrollInterval(interval);
|
||||
}
|
||||
|
||||
function interval2Speed(interval: number) {
|
||||
if (interval === MAX_INTERVAL) {
|
||||
return 0;
|
||||
}
|
||||
return ((MAX_INTERVAL - interval) * 100) / MAX_INTERVAL;
|
||||
}
|
||||
|
||||
function speed2Interval(speed: number) {
|
||||
return MAX_INTERVAL - speed * (MAX_INTERVAL / 100);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className="h-[150px] w-[60px] py-3">
|
||||
<Slider
|
||||
vertical
|
||||
marks={{
|
||||
0: <span>{t('mouse.slow')}</span>,
|
||||
100: <span>{t('mouse.fast')}</span>
|
||||
}}
|
||||
range={false}
|
||||
included={false}
|
||||
step={10}
|
||||
defaultValue={scrollSpeed}
|
||||
onChange={update}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<GaugeIcon size={18} />
|
||||
<span>{t('mouse.speed')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
87
web/src/pages/desktop/menu/power/index.tsx
Normal file
87
web/src/pages/desktop/menu/power/index.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Divider, Switch, Tooltip } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { LoaderCircleIcon, PowerIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/vm';
|
||||
import * as localstorage from '@/lib/localstorage.ts';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { PowerLong } from './power-long.tsx';
|
||||
import { PowerShort } from './power-short.tsx';
|
||||
import { Reset } from './reset.tsx';
|
||||
|
||||
export const Power = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isPowerOn, setIsPowerOn] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getLed();
|
||||
const interval = setInterval(getLed, 5000);
|
||||
|
||||
const powerConfirm = localstorage.getPowerConfirm();
|
||||
setShowConfirm(powerConfirm);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function getLed() {
|
||||
try {
|
||||
const rsp = await api.getGpio();
|
||||
if (rsp.code === 0) {
|
||||
setIsPowerOn(rsp.data.pwr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function updateShowConfirm(value: boolean) {
|
||||
setShowConfirm(value);
|
||||
localstorage.setPowerConfirm(value);
|
||||
}
|
||||
|
||||
const icon = (
|
||||
<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>
|
||||
);
|
||||
|
||||
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('power.title')}</span>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tooltip title={t('power.showConfirmTip')} placement="right">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-400">{t('power.showConfirm')}</span>
|
||||
|
||||
<Switch size="small" checked={showConfirm} onChange={updateShowConfirm} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '10px 0 15px 0' }} />
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Reset showConfirm={showConfirm} isLoading={isLoading} setIsLoading={setIsLoading} />
|
||||
<PowerShort showConfirm={showConfirm} isLoading={isLoading} setIsLoading={setIsLoading} />
|
||||
<PowerLong showConfirm={showConfirm} isLoading={isLoading} setIsLoading={setIsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <MenuItem title={t('power.title')} icon={icon} content={content} />;
|
||||
};
|
||||
67
web/src/pages/desktop/menu/power/power-long.tsx
Normal file
67
web/src/pages/desktop/menu/power/power-long.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { Popconfirm, Slider } from 'antd';
|
||||
import { CirclePowerIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/vm.ts';
|
||||
|
||||
type PowerLongProps = {
|
||||
showConfirm: boolean;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
};
|
||||
|
||||
export const PowerLong = ({ showConfirm, isLoading, setIsLoading }: PowerLongProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [duration, setDuration] = useState(8);
|
||||
|
||||
function power() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
api.setGpio('power', duration * 1000).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showConfirm ? (
|
||||
<Popconfirm
|
||||
placement="bottomLeft"
|
||||
title={t('power.powerConfirm')}
|
||||
okText={t('power.okBtn')}
|
||||
cancelText={t('power.cancelBtn')}
|
||||
onConfirm={power}
|
||||
color="#404040"
|
||||
>
|
||||
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70">
|
||||
<CirclePowerIcon size={16} />
|
||||
<span>{t('power.powerLong')}</span>
|
||||
<div className="flex h-full items-start text-xs text-neutral-500">{`${duration}s`}</div>
|
||||
</div>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70"
|
||||
onClick={power}
|
||||
>
|
||||
<CirclePowerIcon size={16} />
|
||||
<span>{t('power.powerLong')}</span>
|
||||
<div className="flex h-full items-start text-xs text-neutral-500">{`${duration}s`}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3">
|
||||
<Slider
|
||||
defaultValue={8}
|
||||
min={1}
|
||||
max={30}
|
||||
tooltip={{ placement: 'bottom' }}
|
||||
onChange={setDuration}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
52
web/src/pages/desktop/menu/power/power-short.tsx
Normal file
52
web/src/pages/desktop/menu/power/power-short.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Popconfirm } from 'antd';
|
||||
import { PowerIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/vm.ts';
|
||||
|
||||
type PowerShortProps = {
|
||||
showConfirm: boolean;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
};
|
||||
|
||||
export const PowerShort = ({ showConfirm, isLoading, setIsLoading }: PowerShortProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function power() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
api.setGpio('power', 800).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showConfirm ? (
|
||||
<Popconfirm
|
||||
placement="bottomLeft"
|
||||
title={t('power.powerConfirm')}
|
||||
okText={t('power.okBtn')}
|
||||
cancelText={t('power.cancelBtn')}
|
||||
onConfirm={power}
|
||||
color="#404040"
|
||||
>
|
||||
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70">
|
||||
<PowerIcon size={16} />
|
||||
<span>{t('power.powerShort')}</span>
|
||||
</div>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70"
|
||||
onClick={power}
|
||||
>
|
||||
<PowerIcon size={16} />
|
||||
<span>{t('power.powerShort')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
52
web/src/pages/desktop/menu/power/reset.tsx
Normal file
52
web/src/pages/desktop/menu/power/reset.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Popconfirm } from 'antd';
|
||||
import { RotateCcwIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { setGpio } from '@/api/vm.ts';
|
||||
|
||||
type ResetProps = {
|
||||
showConfirm: boolean;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
};
|
||||
|
||||
export const Reset = ({ showConfirm, isLoading, setIsLoading }: ResetProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function reset() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
setGpio('reset', 800).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showConfirm ? (
|
||||
<Popconfirm
|
||||
placement="bottomLeft"
|
||||
title={t('power.resetConfirm')}
|
||||
okText={t('power.okBtn')}
|
||||
cancelText={t('power.cancelBtn')}
|
||||
onConfirm={reset}
|
||||
color="#404040"
|
||||
>
|
||||
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70">
|
||||
<RotateCcwIcon size={16} />
|
||||
<span>{t('power.reset')}</span>
|
||||
</div>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70"
|
||||
onClick={reset}
|
||||
>
|
||||
<RotateCcwIcon size={16} />
|
||||
<span>{t('power.reset')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
13
web/src/pages/desktop/menu/screen/constants.ts
Normal file
13
web/src/pages/desktop/menu/screen/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const QualityMap = new Map([
|
||||
[1, 100],
|
||||
[2, 80],
|
||||
[3, 60],
|
||||
[4, 50]
|
||||
]);
|
||||
|
||||
export const BitRateMap = new Map([
|
||||
[1, 5000],
|
||||
[2, 3000],
|
||||
[3, 2000],
|
||||
[4, 1000]
|
||||
]);
|
||||
117
web/src/pages/desktop/menu/screen/fps.tsx
Normal file
117
web/src/pages/desktop/menu/screen/fps.tsx
Normal 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' }
|
||||
];
|
||||
|
||||
const defaultFps = [60, 30, 24];
|
||||
|
||||
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);
|
||||
if (isCustomize) {
|
||||
setIsCustomize(false);
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* default fps list */}
|
||||
{fpsList.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex cursor-pointer select-none items-center rounded py-1.5 pl-1 hover:bg-neutral-700/70"
|
||||
onClick={() => update(item.key)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
{item.key === fps && <CheckIcon size={14} />}
|
||||
</div>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* customize fps */}
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center rounded py-1.5 pl-1 pr-5 hover:bg-neutral-700/70"
|
||||
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 text-blue-500">
|
||||
<CheckIcon size={14} />
|
||||
</div>
|
||||
<span>Customize</span>
|
||||
<span className="text-xs">{`(${fps}Hz)`}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCustomize && (
|
||||
<div className="flex w-[140px] 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" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<ScanBarcodeIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.fps')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
69
web/src/pages/desktop/menu/screen/frame-detect.tsx
Normal file
69
web/src/pages/desktop/menu/screen/frame-detect.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { LoaderCircleIcon, Tally4Icon, Tally5Icon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/stream.ts';
|
||||
import * as ls from '@/lib/localstorage.ts';
|
||||
|
||||
export const FrameDetect = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const enabled = ls.getFrameDetect();
|
||||
if (enabled) {
|
||||
setIsEnabled(true);
|
||||
} else {
|
||||
api.updateFrameDetect(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function update() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
const enabled = !isEnabled;
|
||||
|
||||
api
|
||||
.updateFrameDetect(enabled)
|
||||
.then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setIsEnabled(enabled);
|
||||
ls.setFrameDetect(enabled);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip placement="rightTop" title={t('screen.frameDetectTip')} color="#262626" arrow>
|
||||
<div
|
||||
className="group 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={clsx(
|
||||
'select-none text-sm',
|
||||
isEnabled ? 'group-hover:text-red-500' : 'group-hover:text-green-500'
|
||||
)}
|
||||
>
|
||||
{t('screen.frameDetect')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
57
web/src/pages/desktop/menu/screen/gop.tsx
Normal file
57
web/src/pages/desktop/menu/screen/gop.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Popover } from 'antd';
|
||||
import { CheckIcon, SquareKanbanIcon } from 'lucide-react';
|
||||
|
||||
import { updateScreen } from '@/api/vm.ts';
|
||||
import { setGop as setCookie } from '@/lib/localstorage';
|
||||
|
||||
type GopProps = {
|
||||
gop: number;
|
||||
setGop: (gop: number) => void;
|
||||
};
|
||||
|
||||
const gopList = [
|
||||
{ key: 10, label: '10' },
|
||||
{ key: 30, label: '30' },
|
||||
{ key: 50, label: '50' },
|
||||
{ key: 100, label: '100' }
|
||||
];
|
||||
|
||||
export const Gop = ({ gop, setGop }: GopProps) => {
|
||||
async function update(value: number) {
|
||||
if (value === gop) return;
|
||||
|
||||
const rsp = await updateScreen('gop', value);
|
||||
if (rsp.code !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGop(value);
|
||||
setCookie(value);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{gopList.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex cursor-pointer select-none items-center rounded py-1 pl-1 pr-6 hover:bg-neutral-700/70"
|
||||
onClick={() => update(item.key)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
{item.key === gop && <CheckIcon size={14} />}
|
||||
</div>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<SquareKanbanIcon size={18} />
|
||||
<span className="select-none text-sm">GOP</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
83
web/src/pages/desktop/menu/screen/index.tsx
Normal file
83
web/src/pages/desktop/menu/screen/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { MonitorIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { updateScreen } from '@/api/vm';
|
||||
import * as ls from '@/lib/localstorage';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { BitRateMap, QualityMap } from './constants.ts';
|
||||
import { Fps } from './fps';
|
||||
import { FrameDetect } from './frame-detect';
|
||||
import { Gop } from './gop.tsx';
|
||||
import { Quality } from './quality';
|
||||
import { Reset } from './reset.tsx';
|
||||
import { Resolution } from './resolution';
|
||||
import { VideoMode } from './video-mode.tsx';
|
||||
|
||||
export const Screen = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const [fps, setFps] = useState(30);
|
||||
const [quality, setQuality] = useState(2);
|
||||
const [gop, setGop] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
updateScreen('type', videoMode === 'mjpeg' ? 0 : 1);
|
||||
updateScreen('resolution', resolution!.height);
|
||||
updateQuality();
|
||||
updateFps();
|
||||
updateGop();
|
||||
}, []);
|
||||
|
||||
async function updateQuality() {
|
||||
const cookieQuality = ls.getQuality();
|
||||
if (!cookieQuality) return;
|
||||
|
||||
const key = cookieQuality >= 1 && cookieQuality <= 4 ? cookieQuality : 2;
|
||||
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
|
||||
|
||||
const rsp = await updateScreen('quality', value);
|
||||
if (rsp.code === 0) {
|
||||
setQuality(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFps() {
|
||||
const cookieFps = ls.getFps();
|
||||
if (!cookieFps) return;
|
||||
|
||||
const rsp = await updateScreen('fps', cookieFps);
|
||||
if (rsp.code === 0) {
|
||||
setFps(cookieFps);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGop() {
|
||||
const cookieGop = ls.getGop();
|
||||
if (!cookieGop) return;
|
||||
|
||||
const rsp = await updateScreen('gop', cookieGop);
|
||||
if (rsp.code === 0) {
|
||||
setGop(cookieGop);
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<VideoMode />
|
||||
<Resolution />
|
||||
<Quality quality={quality} setQuality={setQuality} />
|
||||
<Fps fps={fps} setFps={setFps} />
|
||||
{videoMode !== 'mjpeg' && <Gop gop={gop} setGop={setGop} />}
|
||||
{videoMode === 'mjpeg' && <FrameDetect />}
|
||||
<Reset />
|
||||
</div>
|
||||
);
|
||||
|
||||
return <MenuItem title={t('screen.title')} icon={<MonitorIcon size={18} />} content={content} />;
|
||||
};
|
||||
65
web/src/pages/desktop/menu/screen/quality.tsx
Normal file
65
web/src/pages/desktop/menu/screen/quality.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Popover } from 'antd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { updateScreen } from '@/api/vm';
|
||||
import { setQuality as setCookie } from '@/lib/localstorage.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { BitRateMap, QualityMap } from './constants.ts';
|
||||
|
||||
type QualityProps = {
|
||||
quality: number;
|
||||
setQuality: (quality: number) => void;
|
||||
};
|
||||
|
||||
export const Quality = ({ quality, setQuality }: QualityProps) => {
|
||||
const { t } = useTranslation();
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
const qualityList = [
|
||||
{ key: 1, label: t('screen.qualityLossless') },
|
||||
{ key: 2, label: t('screen.qualityHigh') },
|
||||
{ key: 3, label: t('screen.qualityMedium') },
|
||||
{ key: 4, label: t('screen.qualityLow') }
|
||||
];
|
||||
|
||||
async function update(key: number) {
|
||||
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
|
||||
|
||||
const rsp = await updateScreen('quality', value);
|
||||
if (rsp.code !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQuality(key);
|
||||
setCookie(key);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{qualityList.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex h-[30px] cursor-pointer select-none items-center rounded pl-1 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => update(item.key)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
{item.key === quality && <CheckIcon size={14} />}
|
||||
</div>
|
||||
<span className="flex w-[50px]">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<SquareActivityIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.quality')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
49
web/src/pages/desktop/menu/screen/reset.tsx
Normal file
49
web/src/pages/desktop/menu/screen/reset.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { RefreshCwIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/vm.ts';
|
||||
|
||||
export const Reset = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isPcie, setIsPcie] = useState(true);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getHardware().then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setIsPcie(rsp.data?.version === 'PCIE');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
function reset() {
|
||||
if (isResetting) return;
|
||||
setIsResetting(true);
|
||||
|
||||
api.resetHdmi().finally(() => {
|
||||
setTimeout(() => {
|
||||
setIsResetting(false);
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPcie && (
|
||||
<div
|
||||
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70"
|
||||
onClick={reset}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={clsx({ 'animate-spin text-blue-500': isResetting })}
|
||||
size={18}
|
||||
/>
|
||||
<span className="select-none text-sm">{t('screen.resetHdmi')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
76
web/src/pages/desktop/menu/screen/resolution.tsx
Normal file
76
web/src/pages/desktop/menu/screen/resolution.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { CheckIcon, CircleHelpIcon, 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/screen.ts';
|
||||
|
||||
const resolutions: TypeResolution[] = [
|
||||
{ width: 0, height: 0 },
|
||||
{ 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((res) => (
|
||||
<div
|
||||
key={res.height}
|
||||
className="flex cursor-pointer select-none items-center rounded py-1.5 pl-1 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => update(res)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
{res.height === resolution?.height && <CheckIcon size={15} />}
|
||||
</div>
|
||||
|
||||
{res.height === 0 ? (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<span>{t('screen.auto')}</span>
|
||||
<Tooltip
|
||||
title={t('screen.autoTips')}
|
||||
placement="right"
|
||||
overlayInnerStyle={{ width: '300px' }}
|
||||
>
|
||||
<CircleHelpIcon size={14} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span>{res.width}</span>
|
||||
<span className="px-1">x</span>
|
||||
<span>{res.height}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<RatioIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.resolution')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
81
web/src/pages/desktop/menu/screen/video-mode.tsx
Normal file
81
web/src/pages/desktop/menu/screen/video-mode.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { CheckIcon, TvMinimalPlayIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { setVideoMode as setCookie } from '@/lib/localstorage.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
const videoModes = [
|
||||
{ key: 'direct', name: 'H.264 (Direct)' },
|
||||
{ key: 'h264', name: 'H.264 (WebRTC)' },
|
||||
{ key: 'mjpeg', name: 'MJPEG' }
|
||||
];
|
||||
|
||||
export const VideoMode = () => {
|
||||
const { t } = useTranslation();
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
const [isDirectSupported, setIsDirectSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isHttps = window.location.protocol === 'https:';
|
||||
const isDecoderSupported = !!window.VideoDecoder;
|
||||
|
||||
setIsDirectSupported(isHttps && isDecoderSupported);
|
||||
}, []);
|
||||
|
||||
function update(mode: string) {
|
||||
if (mode === videoMode) return;
|
||||
|
||||
setCookie(mode);
|
||||
|
||||
// reload after changing video mode
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!isDirectSupported && (
|
||||
<Tooltip
|
||||
placement="right"
|
||||
title={t('screen.videoDirectTips')}
|
||||
overlayStyle={{ maxWidth: '270px' }}
|
||||
>
|
||||
<div className="flex cursor-not-allowed select-none items-center rounded py-1.5 pl-1 pr-5 text-neutral-500 hover:bg-neutral-700/70">
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500"></div>
|
||||
<span>H.264 (Direct)</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{videoModes.map(
|
||||
(mode) =>
|
||||
(isDirectSupported || mode.key !== 'direct') && (
|
||||
<div
|
||||
key={mode.key}
|
||||
className="flex cursor-pointer select-none items-center rounded py-1.5 pl-1 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => update(mode.key)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
{mode.key === videoMode && <CheckIcon size={15} />}
|
||||
</div>
|
||||
<span>{mode.name}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<TvMinimalPlayIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.video')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
195
web/src/pages/desktop/menu/script/index.tsx
Normal file
195
web/src/pages/desktop/menu/script/index.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { Button, Divider, Popconfirm } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { ChevronRightIcon, FileJsonIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/script.ts';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { Run } from './run';
|
||||
|
||||
export const Script = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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('');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<span className="text-base font-bold text-neutral-300">{t('script.title')}</span>
|
||||
|
||||
<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/70'
|
||||
)}
|
||||
>
|
||||
<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 (
|
||||
<>
|
||||
<MenuItem
|
||||
title={t('script.title')}
|
||||
icon={<FileJsonIcon size={18} />}
|
||||
content={content}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
|
||||
{isRunning && <Run script={currentScript} setIsRunning={setIsRunning} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
63
web/src/pages/desktop/menu/script/run.tsx
Normal file
63
web/src/pages/desktop/menu/script/run.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
51
web/src/pages/desktop/menu/settings/about/community.tsx
Normal file
51
web/src/pages/desktop/menu/settings/about/community.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { GithubOutlined, XOutlined } from '@ant-design/icons';
|
||||
import { BookOpenIcon, MessageCircleQuestionIcon, MessageSquareIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const Community = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const communities = [
|
||||
{ name: 'Document', icon: <BookOpenIcon size={24} />, url: 'https://wiki.sipeed.com/nanokvm' },
|
||||
{
|
||||
name: 'GitHub',
|
||||
icon: <GithubOutlined style={{ fontSize: '20px' }} width={24} height={24} />,
|
||||
url: 'https://github.com/sipeed/NanoKVM'
|
||||
},
|
||||
{
|
||||
name: 'X',
|
||||
icon: <XOutlined style={{ fontSize: '20px' }} width={24} height={24} />,
|
||||
url: 'https://twitter.com/SipeedIO'
|
||||
},
|
||||
{
|
||||
name: 'Discussion',
|
||||
icon: <MessageSquareIcon size={24} />,
|
||||
url: 'https://maixhub.com/discussion/nanokvm'
|
||||
},
|
||||
{
|
||||
name: 'FAQ',
|
||||
icon: <MessageCircleQuestionIcon size={24} />,
|
||||
url: 'https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/faq.html'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-5 text-neutral-400">{t('settings.about.community')}</div>
|
||||
|
||||
<div className="my-3 flex space-x-3">
|
||||
{communities.map((community) => (
|
||||
<a
|
||||
key={community.name}
|
||||
className="flex h-[64px] w-[80px] flex-col items-center justify-center space-y-2 rounded-lg text-neutral-300 outline outline-1 outline-neutral-800 hover:bg-neutral-800 hover:text-white focus:bg-neutral-800 md:h-[72px] md:w-[100px]"
|
||||
href={community.url}
|
||||
target="_blank"
|
||||
>
|
||||
{community.icon}
|
||||
<span className="text-xs">{community.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
103
web/src/pages/desktop/menu/settings/about/hostname.tsx
Normal file
103
web/src/pages/desktop/menu/settings/about/hostname.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { Button, Input } from 'antd';
|
||||
import { ClipboardPenIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/vm.ts';
|
||||
|
||||
export const Hostname = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hostname, setHostname] = useState('');
|
||||
|
||||
const [editState, setEditState] = useState<'' | 'editing' | 'edited'>('');
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getHostname();
|
||||
}, []);
|
||||
|
||||
function getHostname() {
|
||||
setIsLoading(true);
|
||||
|
||||
api
|
||||
.getHostname()
|
||||
.then((rsp) => {
|
||||
if (rsp.data?.hostname) {
|
||||
setHostname(rsp.data?.hostname);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function showInput() {
|
||||
setInput(hostname);
|
||||
setEditState('editing');
|
||||
}
|
||||
|
||||
function update() {
|
||||
if (input === hostname) {
|
||||
setEditState('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
api
|
||||
.setHostname(input)
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setHostname(input);
|
||||
setEditState('edited');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between pt-4">
|
||||
<span>{t('settings.about.hostname')}</span>
|
||||
|
||||
{editState === 'editing' ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Input
|
||||
disabled={isLoading}
|
||||
style={{ width: 150 }}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<Button size="small" icon={<CheckOutlined />} onClick={update} />
|
||||
<Button size="small" icon={<CloseOutlined />} onClick={() => setEditState('')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{hostname}</span>
|
||||
<div
|
||||
className="size-[16px] cursor-pointer text-neutral-500 hover:text-blue-500"
|
||||
onClick={showInput}
|
||||
>
|
||||
<ClipboardPenIcon size={16} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editState === 'edited' && (
|
||||
<div className="flex w-full justify-end pt-1 text-xs text-green-500">
|
||||
{t('settings.about.hostnameUpdated')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
23
web/src/pages/desktop/menu/settings/about/index.tsx
Normal file
23
web/src/pages/desktop/menu/settings/about/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Divider } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Community } from './community.tsx';
|
||||
import { Hostname } from './hostname.tsx';
|
||||
import { Information } from './information.tsx';
|
||||
|
||||
export const About = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-base font-bold">{t('settings.about.title')}</div>
|
||||
<Divider />
|
||||
|
||||
<Information />
|
||||
<Hostname />
|
||||
<Divider />
|
||||
|
||||
<Community />
|
||||
</>
|
||||
);
|
||||
};
|
||||
109
web/src/pages/desktop/menu/settings/about/information.tsx
Normal file
109
web/src/pages/desktop/menu/settings/about/information.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { CircleHelpIcon, EthernetPortIcon, WifiIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/vm.ts';
|
||||
|
||||
type IP = {
|
||||
name: string;
|
||||
addr: string;
|
||||
version: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type Info = {
|
||||
ips: IP[];
|
||||
mdns: string;
|
||||
image: string;
|
||||
application: string;
|
||||
deviceKey: string;
|
||||
};
|
||||
|
||||
export const Information = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [information, setInformation] = useState<Info>();
|
||||
|
||||
useEffect(() => {
|
||||
api.getInfo().then((rsp: any) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setInformation(rsp.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-5 text-neutral-400">{t('settings.about.information')}</div>
|
||||
|
||||
<div className="flex w-full flex-col space-y-4">
|
||||
{/* IP list */}
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<span>{t('settings.about.ip')}</span>
|
||||
{information?.ips && information.ips.length > 0 ? (
|
||||
<div className="flex flex-col space-y-1">
|
||||
{information.ips.map((ip) => (
|
||||
<div key={ip.addr} className="flex items-center justify-end space-x-2">
|
||||
<span>{ip.addr}</span>
|
||||
<div className="size-[16px] text-neutral-500">
|
||||
{ip.type === 'Wireless' ? (
|
||||
<WifiIcon size={16} />
|
||||
) : (
|
||||
<EthernetPortIcon size={16} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* mDNS */}
|
||||
{!!information?.mdns && (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>{t('settings.about.mdns')}</span>
|
||||
<span>{information.mdns}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* image version */}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{t('settings.about.image')}</span>
|
||||
<Tooltip
|
||||
title={t('settings.about.imageTip')}
|
||||
className="cursor-pointer text-neutral-500"
|
||||
placement="right"
|
||||
>
|
||||
<CircleHelpIcon size={15} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<span>{information ? information.image : '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* application version */}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{t('settings.about.application')}</span>
|
||||
<Tooltip
|
||||
title={t('settings.about.applicationTip')}
|
||||
className="cursor-pointer text-neutral-500"
|
||||
placement="right"
|
||||
>
|
||||
<CircleHelpIcon size={15} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<span>{information ? information.application : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
54
web/src/pages/desktop/menu/settings/account/index.tsx
Normal file
54
web/src/pages/desktop/menu/settings/account/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Divider } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import * as api from '@/api/auth.ts';
|
||||
|
||||
import { Logout } from './logout.tsx';
|
||||
|
||||
export const Account = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.getAccount().then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setUsername(rsp.data.username);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
function changePassword() {
|
||||
navigate('/auth/password');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-base font-bold">{t('settings.account.title')}</div>
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{t('settings.account.webAccount')}</span>
|
||||
<span>{username ? username : '-'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{t('settings.account.password')}</span>
|
||||
<span
|
||||
className="cursor-pointer text-blue-500 hover:text-blue-500/80"
|
||||
onClick={changePassword}
|
||||
>
|
||||
{t('settings.account.updateBtn')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<Logout />
|
||||
</>
|
||||
);
|
||||
};
|
||||
40
web/src/pages/desktop/menu/settings/account/logout.tsx
Normal file
40
web/src/pages/desktop/menu/settings/account/logout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { LogoutOutlined } from '@ant-design/icons';
|
||||
import { Button, Popconfirm } 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';
|
||||
|
||||
export const Logout = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function logout() {
|
||||
api.logout().then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
removeToken();
|
||||
navigate('/auth/login');
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center pt-3">
|
||||
<Popconfirm
|
||||
placement="bottom"
|
||||
title={t('settings.account.logoutDesc')}
|
||||
okText={t('settings.account.okBtn')}
|
||||
cancelText={t('settings.account.cancelBtn')}
|
||||
onConfirm={logout}
|
||||
>
|
||||
<Button danger type="primary" size="large" shape="round" icon={<LogoutOutlined />}>
|
||||
{t('settings.account.logoutBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
25
web/src/pages/desktop/menu/settings/appearance/index.tsx
Normal file
25
web/src/pages/desktop/menu/settings/appearance/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Divider } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Language } from './language.tsx';
|
||||
import { MenuBar } from './menu-bar.tsx';
|
||||
import { WebTitle } from './web-title.tsx';
|
||||
|
||||
export const Appearance = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-base font-bold">{t('settings.appearance.title')}</div>
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col space-y-6">
|
||||
<Language />
|
||||
<WebTitle />
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<MenuBar />
|
||||
</>
|
||||
);
|
||||
};
|
||||
38
web/src/pages/desktop/menu/settings/appearance/language.tsx
Normal file
38
web/src/pages/desktop/menu/settings/appearance/language.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Select } from 'antd';
|
||||
import { LanguagesIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import languages from '@/i18n/languages.ts';
|
||||
import { setLanguage } from '@/lib/localstorage.ts';
|
||||
|
||||
export const Language = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const options = languages.map((language) => ({
|
||||
value: language.key,
|
||||
label: language.name
|
||||
}));
|
||||
|
||||
function changeLanguage(value: string) {
|
||||
if (i18n.language === value) return;
|
||||
|
||||
i18n.changeLanguage(value);
|
||||
setLanguage(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-1">
|
||||
<LanguagesIcon size={16} />
|
||||
<span>{t('settings.appearance.language')}</span>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
defaultValue={i18n.language}
|
||||
style={{ width: 180 }}
|
||||
options={options}
|
||||
onSelect={changeLanguage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
web/src/pages/desktop/menu/settings/appearance/menu-bar.tsx
Normal file
64
web/src/pages/desktop/menu/settings/appearance/menu-bar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Switch } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import {
|
||||
DiscIcon,
|
||||
DownloadIcon,
|
||||
FileJsonIcon,
|
||||
NetworkIcon,
|
||||
PowerIcon,
|
||||
TerminalSquareIcon
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as ls from '@/lib/localstorage.ts';
|
||||
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
|
||||
|
||||
export const MenuBar = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
|
||||
|
||||
const items = [
|
||||
{ key: 'image', icon: <DiscIcon size={16} /> },
|
||||
{ key: 'download', icon: <DownloadIcon size={16} /> },
|
||||
{ key: 'script', icon: <FileJsonIcon size={15} /> },
|
||||
{ key: 'terminal', icon: <TerminalSquareIcon size={16} /> },
|
||||
{ key: 'wol', icon: <NetworkIcon size={16} /> },
|
||||
{ key: 'power', icon: <PowerIcon size={16} /> }
|
||||
];
|
||||
|
||||
function updateItems(key: string) {
|
||||
const exist = menuDisabledItems.includes(key);
|
||||
|
||||
const newItems = exist
|
||||
? menuDisabledItems.filter((item) => item !== key)
|
||||
: [...menuDisabledItems, key];
|
||||
|
||||
setMenuDisabledItems(newItems);
|
||||
ls.setMenuDisabledItems(newItems);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-neutral-400">{t('settings.appearance.menuBar')}</span>
|
||||
<span className="text-xs text-neutral-500">{t('settings.appearance.menuBarDesc')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 pt-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-1">
|
||||
{item.icon}
|
||||
<span>{t(`${item.key}.title`)}</span>
|
||||
</div>
|
||||
<Switch
|
||||
value={!menuDisabledItems.includes(item.key)}
|
||||
onChange={() => updateItems(item.key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user