Refactor: Rename NanoKVM to BatchuKVM and update server URL

This commit is contained in:
2025-12-09 20:35:38 +09:00
commit 8cf674c9e5
396 changed files with 54380 additions and 0 deletions

9
web/.editorconfig Normal file
View File

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

3
web/.env.development Normal file
View 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
View File

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

1
web/.prettierignore Normal file
View File

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

36
web/.prettierrc.yaml Normal file
View 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
View 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
View 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
View 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
View File

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

71
web/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

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

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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
View 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
View 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');
}

View 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
View 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
View 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
View 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
View 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
View 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);
}

View File

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

166
web/src/api/vm.ts Normal file
View 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');
}

View File

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

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1,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

View 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);
}
}

View 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;
}

View File

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

View File

@@ -0,0 +1,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>
);
};

View 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" />;
};

View 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>
);
};

View 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>
);
};

View 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
View 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
View 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;

View 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
View 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
View 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;

View File

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

13
web/src/jotai/mouse.ts Normal file
View 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
View 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);

View 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
View File

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

9
web/src/lib/encrypt.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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 />
</>
);
};

View 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 <></>;
};

View 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]
]);

View 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>
);
};

View 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: '&#60;<br/>&#62;',
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'
];

View 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}
/>
);
};

View 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>
);
};

View 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}
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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>
);
}

View 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 />
</>
}
/>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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} />;
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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} />;
};

View 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>
</>
);
};

View 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>
)}
</>
);
};

View 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>
)}
</>
);
};

View 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]
]);

View File

@@ -0,0 +1,117 @@
import { useRef, useState } from 'react';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, InputNumber, Popover } from 'antd';
import { CheckIcon, ScanBarcodeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setFps as setCookie } from '@/lib/localstorage';
const fpsList = [
{ key: 60, label: '60Hz' },
{ key: 30, label: '30Hz' },
{ key: 24, label: '24Hz' }
];
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>
);
};

View 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>
);
};

View 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>
);
};

View 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} />;
};

View 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>
);
};

View 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>
)}
</>
);
};

View 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>
);
};

View 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>
);
};

View 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} />}
</>
);
};

View File

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

View File

@@ -0,0 +1,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>
</>
);
};

View 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>
)}
</>
);
};

View 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 />
</>
);
};

View 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>
</>
);
};

View 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 />
</>
);
};

View 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>
);
};

View 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 />
</>
);
};

View 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>
);
};

View 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