Update App version to 0.0.3

This commit is contained in:
luckfox-eng29
2025-09-25 16:51:53 +08:00
parent 15d276652c
commit 4e82b8a11c
59 changed files with 2841 additions and 794 deletions

2
.gitignore vendored
View File

@@ -6,6 +6,6 @@ static/*
package-lock.json
package.json
node_modules/
ui/.i18n_extractor.json
device-tests.tar.gz

View File

@@ -2,8 +2,8 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV ?= 0.0.2-dev
VERSION ?= 0.0.2
VERSION_DEV ?= 0.0.3-dev
VERSION ?= 0.0.3
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := kvm

30
README.md Normal file
View File

@@ -0,0 +1,30 @@
![luckfox](https://github.com/LuckfoxTECH/luckfox-pico/assets/144299491/cec5c4a5-22b9-4a9a-abb1-704b11651e88)
[中文](./README_CN.md)
# Luckfox PicoKVM
Luckfox PicoKVM is a lightweight IP KVM operation and maintenance tool. It allows remote access to a target devices display and simulates HID input over the network, enabling contactless operation and management of development boards, PCs, and servers. The product delivers stable, low-latency video capture and remote control, making it well-suited for scenarios such as remote computer management and server maintenance. The software is based on a secondary development of JetKVM.
## Features
* **Micro SD card support**: Used for software boot settings or storage expansion
* **USB multifunction configuration**: Supports emulating a USB sound card or MTP device. Files in the shared MTP directory can be uploaded or downloaded via HTTP
* **Serial port control**: Connects to the serial port of the controlled device for control and debugging
* **IO level configuration**: Controls expansion IO output (high/low level)
* **1.54-inch touchscreen**: Displays IP address, connection status, and system runtime status
* **Multiple remote access methods**: Remote management via WebRTC through cross-network connections or FRP reverse proxy
## Compilation
* Compile frontend only
```bash
make frontend
```
* Compile backend only
```bash
make build_dev
```
* Full compilation
```bash
make build_release
```
* Compile to generate **bin/kvm_app**, then upload the file to Luckfox PicoKVM via SSH or MTP, replacing **/userdata/picokvm/bin/kvm_app**
## Detailed Usage Instructions
[Luckfox PicoKVM](https://wiki.luckfox.com/Luckfox-PicoKVM/)

30
README_CN.md Normal file
View File

@@ -0,0 +1,30 @@
![luckfox](https://github.com/LuckfoxTECH/luckfox-pico/assets/144299491/cec5c4a5-22b9-4a9a-abb1-704b11651e88)
[English](./README.md)
# Luckfox PicoKVM
Luckfox PicoKVM 是一款轻量级 IP KVM 运维工具,支持通过网络远程获取目标设备画面并模拟 HID 输入,实现对开发板、电脑及服务器等系统的无接触运维管理。该产品具备稳定、低延迟的视频采集和远程控制能力,广泛适用于远程电脑控制和服务器维护等场景。软件基于 JetKVM 二次开发。
## 特性
* **Micro SD 卡支持**:可用于软件启动设置或存储拓展
* **USB 多功能配置**:支持模拟 USB 声卡或 MTP 设备,在 MTP 共享目录下可通过 HTTP 上传或下载文件
* **串口控制**:可连接受控设备的串口,实现对受控设备的串口控制和调试
* **IO 电平配置**:控制拓展 IO 输出高低电平
* **1.54 寸触控屏幕**:显示 IP 地址、连接状态及系统运行状态
* **多种远程访问方案**:使用 WebRTC 通过异域组网或 FRP 端口反向代理进行远程管理
## 编译
* 仅编译前端
```bash
make frontend
```
* 仅编译后端
```bash
make build_dev
```
* 完整编译
```bash
make build_release
```
* 编译生成 **bin/kvm_app**,通过 ssh 或 MTP 将文件上传到 Luckfox PicoKVM替换 **/userdata/picokvm/bin/kvm_app**
## 详细使用说明
[Luckfox PicoKVM](https://wiki.luckfox.com/zh/Luckfox-PicoKVM/)

View File

@@ -114,6 +114,9 @@ type Config struct {
TimeZone string `json:"time_zone"`
LEDGreenMode string `json:"led_green_mode"`
LEDYellowMode string `json:"led_yellow_mode"`
AutoMountSystemInfo bool `json:"auto_mount_system_info_img"`
EasytierAutoStart bool `json:"easytier_autostart"`
EasytierConfig EasytierConfig `json:"easytier_config"`
}
const configPath = "/userdata/kvm_config.json"
@@ -146,17 +149,18 @@ var defaultConfig = &Config{
Audio: false, //At any given time, only one of Audio and Mtp can be set to true
Mtp: false,
},
NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO",
ZeroTierAutoStart: false,
TailScaleAutoStart: false,
TailScaleXEdge: false,
FrpcAutoStart: false,
IO0Status: true,
IO1Status: true,
AudioMode: "disabled",
LEDGreenMode: "network-rx",
LEDYellowMode: "kernel-activity",
NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO",
ZeroTierAutoStart: false,
TailScaleAutoStart: false,
TailScaleXEdge: false,
FrpcAutoStart: false,
IO0Status: true,
IO1Status: true,
AudioMode: "disabled",
LEDGreenMode: "network-rx",
LEDYellowMode: "kernel-activity",
AutoMountSystemInfo: true,
}
var (
@@ -174,15 +178,16 @@ func LoadConfig() {
}
// load the default config
config = defaultConfig
if config.UsbConfig.SerialNumber == "" {
if defaultConfig.UsbConfig.SerialNumber == "" {
serialNumber, err := extractSerialNumber()
if err != nil {
logger.Warn().Err(err).Msg("failed to extract serial number")
} else {
config.UsbConfig.SerialNumber = serialNumber
defaultConfig.UsbConfig.SerialNumber = serialNumber
}
}
loadedConfig := *defaultConfig
config = &loadedConfig
file, err := os.Open(configPath)
if err != nil {
@@ -192,7 +197,6 @@ func LoadConfig() {
defer file.Close()
// load and merge the default config with the user config
loadedConfig := *defaultConfig
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Warn().Err(err).Msg("config file JSON parsing failed")
os.Remove(configPath)

View File

@@ -80,7 +80,15 @@ func (u *UsbGadget) IsUDCBound() (bool, error) {
// BindUDC binds the gadget to the UDC.
func (u *UsbGadget) BindUDC() error {
err := os.WriteFile(path.Join(udcPath, "bind"), []byte(u.udc), 0644)
err := os.WriteFile(udcPath, []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("error binding UDC: %w", err)
}
return nil
}
func (u *UsbGadget) BindUDCToDWC3() error {
err := os.WriteFile(path.Join(dwc3Path, "bind"), []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("error binding UDC: %w", err)
}
@@ -89,7 +97,15 @@ func (u *UsbGadget) BindUDC() error {
// UnbindUDC unbinds the gadget from the UDC.
func (u *UsbGadget) UnbindUDC() error {
err := os.WriteFile(path.Join(udcPath, " "), []byte(u.udc), 0644)
err := os.WriteFile(udcPath, []byte("none"), 0644)
if err != nil {
return fmt.Errorf("error unbinding UDC: %w", err)
}
return nil
}
func (u *UsbGadget) UnbindUDCToDWC3() error {
err := os.WriteFile(path.Join(dwc3Path, "unbind"), []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("error unbinding UDC: %w", err)
}

View File

@@ -84,6 +84,7 @@ type UsbGadget struct {
log *zerolog.Logger
logSuppressionCounter map[string]int
logLock sync.Mutex
}
const configFSPath = "/sys/kernel/config"

View File

@@ -82,6 +82,9 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool)
}
func (u *UsbGadget) logWithSupression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) {
u.logLock.Lock()
defer u.logLock.Unlock()
if _, ok := u.logSuppressionCounter[counterName]; !ok {
u.logSuppressionCounter[counterName] = 0
} else {

View File

@@ -628,9 +628,9 @@ func rpcGetUsbEmulationState() (bool, error) {
func rpcSetUsbEmulationState(enabled bool) error {
if enabled {
return gadget.BindUDC()
return gadget.BindUDCToDWC3()
} else {
return gadget.UnbindUDC()
return gadget.UnbindUDCToDWC3()
}
}
@@ -663,7 +663,8 @@ func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error {
}
func rpcResetConfig() error {
config = defaultConfig
loadedConfig := *defaultConfig
config = &loadedConfig
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to reset config: %w", err)
}
@@ -1029,6 +1030,18 @@ func rpcGetLedYellowMode() (string, error) {
return config.LEDYellowMode, nil
}
func rpcGetAutoMountSystemInfo() (bool, error) {
return config.AutoMountSystemInfo, nil
}
func rpcSetAutoMountSystemInfo(enabled bool) error {
config.AutoMountSystemInfo = enabled
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}},
@@ -1084,6 +1097,8 @@ var rpcHandlers = map[string]RPCHandler{
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"mountWithSDStorage": {Func: rpcMountWithSDStorage, Params: []string{"filename", "mode"}},
"setAutoMountSystemInfo": {Func: rpcSetAutoMountSystemInfo, Params: []string{"enabled"}},
"getAutoMountSystemInfo": {Func: rpcGetAutoMountSystemInfo},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
@@ -1121,7 +1136,7 @@ var rpcHandlers = map[string]RPCHandler{
"getSDMountStatus": {Func: rpcGetSDMountStatus},
"loginTailScale": {Func: rpcLoginTailScale, Params: []string{"xEdge"}},
"logoutTailScale": {Func: rpcLogoutTailScale},
"canelTailScale": {Func: rpcCanelTailScale},
"cancelTailScale": {Func: rpcCancelTailScale},
"getTailScaleSettings": {Func: rpcGetTailScaleSettings},
"loginZeroTier": {Func: rpcLoginZeroTier, Params: []string{"networkID"}},
"logoutZeroTier": {Func: rpcLogoutZeroTier, Params: []string{"networkID"}},
@@ -1134,4 +1149,10 @@ var rpcHandlers = map[string]RPCHandler{
"getFrpcStatus": {Func: rpcGetFrpcStatus},
"getFrpcToml": {Func: rpcGetFrpcToml},
"getFrpcLog": {Func: rpcGetFrpcLog},
"startEasyTier": {Func: rpcStartEasyTier, Params: []string{"name", "secret", "node"}},
"stopEasyTier": {Func: rpcStopEasyTier},
"getEasyTierStatus": {Func: rpcGetEasyTierStatus},
"getEasyTierConfig": {Func: rpcGetEasyTierConfig},
"getEasyTierLog": {Func: rpcGetEasyTierLog},
"getEasyTierNodeInfo": {Func: rpcGetEasyTierNodeInfo},
}

2
ota.go
View File

@@ -59,7 +59,7 @@ var UpdateMetadataUrls = []string{
"https://api.github.com/repos/luckfox-eng29/kvm/releases/latest",
}
var builtAppVersion = "0.0.2+dev"
var builtAppVersion = "0.0.3+dev"
var updateSource = "github"

405
ui/package-lock.json generated
View File

@@ -64,6 +64,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0",
"i18n-auto-extractor": "^1.2.3",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
@@ -2344,6 +2345,26 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2744,6 +2765,35 @@
"node": ">=18"
}
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-spinners": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3124,6 +3174,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz",
"integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==",
"dev": true,
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@@ -3138,6 +3195,20 @@
"node": ">=10.13.0"
}
},
"node_modules/enquirer": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-colors": "^4.1.1",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/es-abstract": {
"version": "1.23.9",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
@@ -4013,6 +4084,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4124,6 +4208,20 @@
"csstype": "^3.0.10"
}
},
"node_modules/google-translate-api-x": {
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/google-translate-api-x/-/google-translate-api-x-10.7.2.tgz",
"integrity": "sha512-GSmbvGMcnULaih2NFgD4Y6840DLAMot90mLWgwoB+FG/QpetyZkFrZkxop8ZxXgOAQXGskFOhGJady8nA6ZJ2g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/AidanWelch"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -4237,6 +4335,39 @@
"node": ">= 0.4"
}
},
"node_modules/i18n-auto-extractor": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/i18n-auto-extractor/-/i18n-auto-extractor-1.2.3.tgz",
"integrity": "sha512-JSthwkUmEtHQmZtzhsjpZGbVWaVekbYmgDoBKRsmQNSrGlVT/eT4OKoKfvpWurHi1fhvKrc6fPTG9yHWAoNp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",
"enquirer": "^2.4.1",
"google-translate-api-x": "^10.7.2",
"js-md5": "^0.8.3",
"ora": "^8.2.0"
},
"bin": {
"i18n-auto-extractor": "dist/index.js"
},
"engines": {
"node": ">=20.11.1"
}
},
"node_modules/i18n-auto-extractor/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4487,6 +4618,19 @@
"node": ">=0.10.0"
}
},
"node_modules/is-interactive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@@ -4618,6 +4762,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -4707,6 +4864,13 @@
"integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==",
"license": "BSD-3-Clause"
},
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5075,6 +5239,49 @@
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
"integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"is-unicode-supported": "^1.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-symbols/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/log-symbols/node_modules/is-unicode-supported": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
"integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5139,6 +5346,19 @@
"node": ">=8.6"
}
},
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
@@ -5386,6 +5606,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5403,6 +5639,72 @@
"node": ">= 0.8.0"
}
},
"node_modules/ora": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
"integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"cli-cursor": "^5.0.0",
"cli-spinners": "^2.9.2",
"is-interactive": "^2.0.0",
"is-unicode-supported": "^2.0.0",
"log-symbols": "^6.0.0",
"stdin-discarder": "^0.2.2",
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ora/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/ora/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -5971,6 +6273,23 @@
"node": ">=4"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -6255,6 +6574,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6264,6 +6596,66 @@
"node": ">=0.10.0"
}
},
"node_modules/stdin-discarder": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
"integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/string-width/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -6359,6 +6751,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",

View File

@@ -75,6 +75,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0",
"i18n-auto-extractor": "^1.2.3",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",

View File

@@ -22,6 +22,7 @@ import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import VolumeControl from "./VolumeControl";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function Actionbar({
requestFullscreen,
@@ -45,6 +46,7 @@ export default function Actionbar({
const [send] = useJsonRpc();
const audioMode = useAudioModeStore(state => state.audioMode);
const setAudioMode = useAudioModeStore(state => state.setAudioMode);
const { $at }= useReactAt();
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
@@ -82,7 +84,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Terminal"
text={$at("Terminal")}
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
/>
@@ -91,7 +93,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Paste text"
text={$at("Paste Text")}
LeadingIcon={MdOutlineContentPasteGo}
onClick={() => {
setDisableFocusTrap(true);
@@ -122,7 +124,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Virtual Media"
text={$at("Virtual Media")}
LeadingIcon={({ className }) => {
return (
<>
@@ -165,7 +167,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Wake"
text={$at("Wake")}
onClick={() => {
setDisableFocusTrap(true);
}}
@@ -215,7 +217,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
text={$at("Virtual Keyboard")}
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
@@ -238,7 +240,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Extension"
text={$at("Extensions")}
LeadingIcon={LuCable}
onClick={() => {
setDisableFocusTrap(true);
@@ -264,7 +266,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
text={$at("Virtual Keyboard")}
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
@@ -273,7 +275,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Connection State"
text={$at("Connection")}
LeadingIcon={({ className }) => (
<LuSignal
className={cx(className, "mb-0.5 text-green-500")}
@@ -306,7 +308,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Settings"
text={$at("Settings")}
LeadingIcon={LuSettings}
onClick={() => navigateTo("/settings")}
/>
@@ -317,7 +319,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Fullscreen"
text={$at("Fullscreen")}
LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()}
/>

View File

@@ -4,6 +4,7 @@ import { Button } from "@/components/Button";
import { GridCard } from "@/components/Card";
import { LifeTimeLabel } from "@/routes/devices.$id.settings.network";
import { NetworkState } from "@/hooks/stores";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function DhcpLeaseCard({
networkState,
@@ -12,12 +13,13 @@ export default function DhcpLeaseCard({
networkState: NetworkState;
setShowRenewLeaseConfirm: (show: boolean) => void;
}) {
const { $at }= useReactAt();
return (
<GridCard>
<div className="animate-fadeIn p-4 opacity-0 animation-duration-500 text-black dark:text-white">
<div className="space-y-3">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
{$at("DHCP Lease Information")}
</h3>
<div className="flex gap-x-6 gap-y-2">
@@ -25,7 +27,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.ip && (
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
IP Address
{$at("IP Address")}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ip}
@@ -36,7 +38,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.netmask && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Subnet Mask
{$at("Subnet Mask")}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.netmask}
@@ -47,7 +49,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.dns && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DNS Servers
{$at("DNS Servers")}
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.dns.map(dns => <div key={dns}>{dns}</div>)}
@@ -58,7 +60,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.broadcast && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Broadcast
{$at("Broadcast Address")}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.broadcast}
@@ -69,7 +71,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.domain && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Domain
{$at("Domain")}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.domain}
@@ -81,7 +83,7 @@ export default function DhcpLeaseCard({
networkState?.dhcp_lease?.ntp_servers.length > 0 && (
<div className="flex justify-between gap-x-8 border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<div className="w-full grow text-sm text-slate-600 dark:text-slate-400">
NTP Servers
{$at("NTP Servers")}
</div>
<div className="shrink text-right text-sm font-medium">
{networkState?.dhcp_lease?.ntp_servers.map(server => (
@@ -94,7 +96,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.hostname && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Hostname
{$at("Hostname")}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.hostname}
@@ -108,7 +110,7 @@ export default function DhcpLeaseCard({
networkState?.dhcp_lease?.routers.length > 0 && (
<div className="flex justify-between pt-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
Gateway
{$at("Gateways")}
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.routers.map(router => (
@@ -121,7 +123,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.server_id && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DHCP Server
{$at("DHCP Server")}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.server_id}
@@ -132,7 +134,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.lease_expiry && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Lease Expires
{$at("Lease Expiry")}
</span>
<span className="text-sm font-medium">
<LifeTimeLabel
@@ -163,7 +165,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.bootp_next_server && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Next Server
{$at("Boot Next Server")}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_next_server}
@@ -174,7 +176,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.bootp_server_name && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Server Name
{$at("Boot Server Name")}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_server_name}
@@ -185,7 +187,7 @@ export default function DhcpLeaseCard({
{networkState?.dhcp_lease?.bootp_file && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot File
{$at("Boot File")}
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_file}
@@ -200,7 +202,7 @@ export default function DhcpLeaseCard({
size="SM"
theme="light"
className="text-red-500"
text="Renew DHCP Lease"
text={$at("Renew DHCP Lease")}
LeadingIcon={LuRefreshCcw}
onClick={() => setShowRenewLeaseConfirm(true)}
/>

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { use, useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
@@ -11,13 +11,19 @@ import LogoLuckfox from "@/assets/logo-luckfox.png";
import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import VpnConnectionStatusCard from "@components/VpnConnectionStatusCard";
import { SelectMenuBasic } from "./SelectMenuBasic";
import { DEVICE_API } from "@/ui.config";
import { useSettingsStore } from "@/hooks/stores";
import api from "../api";
import { isOnDevice } from "../main";
import { LinkButton } from "./Button";
import { useReactAt } from 'i18n-auto-extractor/react'
import enJSON from '../locales/en.json'
import zhJSON from '../locales/zh.json'
interface NavbarProps {
isLoggedIn: boolean;
primaryLinks?: { title: string; to: string }[];
@@ -52,6 +58,26 @@ export default function DashboardNavbar({
const usbState = useHidStore(state => state.usbState);
const language = useSettingsStore(state => state.language);
const setLanguage = useSettingsStore(state => state.setLanguage);
const LangOptions = [
{ value: "en", label: "English"},
{ value: "zh", label: "中文"},
];
// default language
const {setCurrentLang,$at,langSet}= useReactAt();
const handleLangChange = (lang: string) => {
setLanguage(lang)
setCurrentLang(lang, lang === 'en' ? enJSON : zhJSON)
};
useEffect(() => {
setCurrentLang(language, language === 'en' ? enJSON : zhJSON)
}, [language, setCurrentLang])
// for testing
//userEmail = "user@example.org";
//picture = "https://placehold.co/32x32"
@@ -94,6 +120,22 @@ export default function DashboardNavbar({
<div className="flex w-full items-center justify-end gap-x-2">
<div className="flex shrink-0 items-center space-x-4">
<div className="hidden items-stretch gap-x-2 md:flex">
<div className="flex items-center gap-x-2">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" className="text-gray-700 dark:text-gray-300" >
<path fill="currentColor"
d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" >
</path>
</svg>
<SelectMenuBasic
size="SM"
label=""
value={language}
onChange={(e) => handleLangChange(e.target.value)}
options={LangOptions}
/>
</div>
{showConnectionStatus && (
<>
<div className="w-[159px]">

View File

@@ -9,8 +9,10 @@ import {
useVideoStore,
} from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { useReactAt } from 'i18n-auto-extractor/react'
export default function InfoBar() {
const { $at } = useReactAt();
const activeKeys = useHidStore(state => state.activeKeys);
const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX);
@@ -53,21 +55,21 @@ export default function InfoBar() {
<div className="flex flex-wrap items-center pl-2 gap-x-4">
{settings.debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "}
<span className="text-xs font-semibold">{$at("Resolution")}:</span>{" "}
<span className="text-xs">{videoSize}</span>
</div>
) : null}
{settings.debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Video Size: </span>
<span className="text-xs font-semibold">{$at("Video Size")}: </span>
<span className="text-xs">{videoClientSize}</span>
</div>
) : null}
{(settings.debugMode && settings.mouseMode == "absolute") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs font-semibold">{$at("Pointer")}:</span>
<span className="text-xs">
{mouseX},{mouseY}
</span>
@@ -76,7 +78,7 @@ export default function InfoBar() {
{(settings.debugMode && settings.mouseMode == "relative") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Last Move:</span>
<span className="text-xs font-semibold">{$at("Last Move")}:</span>
<span className="text-xs">
{mouseMove ?
`${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` :
@@ -87,20 +89,20 @@ export default function InfoBar() {
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span>
<span className="text-xs font-semibold">{$at("USB State")}:</span>
<span className="text-xs">{usbState}</span>
</div>
)}
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HDMI State:</span>
<span className="text-xs font-semibold">{$at("HDMI State")}:</span>
<span className="text-xs">{hdmiState}</span>
</div>
)}
{showPressedKeys && (
<div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span>
<span className="text-xs font-semibold">{$at("Keys")}:</span>
<h2 className="text-xs">
{[
...activeKeys.map(

View File

@@ -2,25 +2,27 @@ import { NetworkState } from "../hooks/stores";
import { LifeTimeLabel } from "../routes/devices.$id.settings.network";
import { GridCard } from "./Card";
import { useReactAt } from 'i18n-auto-extractor/react'
export default function Ipv6NetworkCard({
networkState,
}: {
networkState: NetworkState;
}) {
const { $at } = useReactAt();
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information
{$at("IPv6 Information")}
</h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
{networkState?.dhcp_lease?.ip && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Link-local
{$at("Link-local")}
</span>
<span className="text-sm font-medium">
{networkState?.ipv6_link_local}

View File

@@ -5,6 +5,7 @@ import { LuEllipsisVertical } from "react-icons/lu";
import Card from "@components/Card";
import { Button, LinkButton } from "@components/Button";
import {useReactAt} from 'i18n-auto-extractor/react'
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
// Allow dates or times to be passed
@@ -50,6 +51,7 @@ export default function KvmCard({
online: boolean;
lastSeen: Date | null;
}) {
const { $at }= useReactAt();
return (
<Card>
<div className="px-5 py-5 space-y-3">
@@ -85,7 +87,7 @@ export default function KvmCard({
<LinkButton
size="MD"
theme="light"
text="Connect to KVM"
text={$at("Connect to KVM")}
LeadingIcon={MdConnectWithoutContact}
textAlign="center"
to={`/devices/${id}`}
@@ -94,7 +96,7 @@ export default function KvmCard({
<Button
size="MD"
theme="light"
text="Troubleshoot Connection"
text={$at("Troubleshoot Connection")}
textAlign="center"
/>
)}
@@ -123,7 +125,7 @@ export default function KvmCard({
className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/rename`}
>
Rename
{$at("Rename")}
</Link>
</div>
</div>
@@ -137,7 +139,7 @@ export default function KvmCard({
className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/deregister`}
>
Deregister from cloud
{$at("Deregister from cloud")}
</Link>
</div>
</div>

View File

@@ -109,7 +109,7 @@ export default function Ansi({ children, className }: AnsiProps) {
col = 0;
} else if (chunk) {
const style = getStyle();
const chars = [...chunk]; // 正确识别 Unicode 码点
const chars = [...chunk];
for (const ch of chars) {
if (col < currentLine.text.length) {
currentLine.text =
@@ -155,7 +155,7 @@ export function LogDialog({
return (
<Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-xl px-3 transition-all duration-300 ease-in-out">
<div className="mx-auto max-w-4xl px-3 transition-all duration-300 ease-in-out">
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-5 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
<div className="space-y-3">
<div className="sm:flex sm:items-start">

View File

@@ -12,6 +12,7 @@ import {
MAX_KEYS_PER_STEP,
} from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel";
import {useReactAt} from 'i18n-auto-extractor/react'
interface ValidationErrors {
name?: string;
@@ -30,7 +31,6 @@ interface MacroFormProps {
onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
onCancel: () => void;
isSubmitting?: boolean;
submitText?: string;
}
export function MacroForm({
@@ -38,8 +38,8 @@ export function MacroForm({
onSubmit,
onCancel,
isSubmitting = false,
submitText = "Save Macro",
}: MacroFormProps) {
const { $at }= useReactAt();
const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
const [errors, setErrors] = useState<ValidationErrors>({});
@@ -176,8 +176,8 @@ export function MacroForm({
<Fieldset>
<InputFieldWithLabel
type="text"
label="Macro Name"
placeholder="Macro Name"
label={$at("Macro Name")}
placeholder={$at("Macro Name")}
value={macro.name}
error={errors.name}
onChange={e => {
@@ -195,8 +195,8 @@ export function MacroForm({
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<FieldLabel
label="Steps"
description={`Keys/modifiers executed in sequence with a delay between each step.`}
label={$at("Steps")}
description={$at("Keys/modifiers executed in sequence with a delay between each step.")}
/>
</div>
<span className="text-slate-500 dark:text-slate-400">
@@ -245,11 +245,11 @@ export function MacroForm({
theme="light"
fullWidth
LeadingIcon={LuPlus}
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ""}`}
text={`${$at("Add Step")} ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} ${$at("max")})` : ""}`}
onClick={() => {
if (isMaxStepsReached) {
showTemporaryError(
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
`${$at("You can only add a maximum of")} ${MAX_STEPS_PER_MACRO} ${$at("steps per macro.")}`,
);
return;
}
@@ -277,11 +277,11 @@ export function MacroForm({
<Button
size="SM"
theme="primary"
text={isSubmitting ? "Saving..." : submitText}
text={isSubmitting ? $at("Saving...") : $at("Save")}
onClick={handleSubmit}
disabled={isSubmitting}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
<Button size="SM" theme="light" text={$at("Cancel")} onClick={onCancel} />
</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import Card from "@/components/Card";
import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel";
import {useReactAt} from 'i18n-auto-extractor/react'
// Filter out modifier keys since they're handled in the modifiers section
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
@@ -86,6 +87,8 @@ export function MacroStepCard({
onDelayChange,
isLastStep
}: MacroStepCardProps) {
const { $at }= useReactAt();
const getFilteredKeys = () => {
const selectedKeys = ensureArray(step.keys);
const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
@@ -128,7 +131,7 @@ export function MacroStepCard({
size="XS"
theme="light"
className="text-red-500 dark:text-red-400"
text="Delete"
text={$at("Delete")}
LeadingIcon={LuTrash2}
onClick={onDelete}
/>
@@ -138,7 +141,7 @@ export function MacroStepCard({
<div className="space-y-4 mt-2">
<div className="w-full flex flex-col gap-2">
<FieldLabel label="Modifiers" />
<FieldLabel label={$at("Modifiers")} />
<div className="inline-flex flex-wrap gap-3">
{Object.entries(groupedModifiers).map(([group, mods]) => (
<div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
@@ -170,7 +173,7 @@ export function MacroStepCard({
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
<FieldLabel label={$at("Keys")} description={`${$at("Maximum")} ${MAX_KEYS_PER_STEP} ${$at("keys per step.")}`} />
</div>
{ensureArray(step.keys) && step.keys.length > 0 && (
<div className="flex flex-wrap gap-1 pb-2">
@@ -205,19 +208,19 @@ export function MacroStepCard({
displayValue={() => keyQuery}
onInputChange={onKeyQueryChange}
options={getFilteredKeys}
disabledMessage="Max keys reached"
disabledMessage={$at("Max keys reached")}
size="SM"
immediate
disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
emptyMessage="No matching keys found"
placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? $at("Max keys reached") : $at("Search for key...")}
emptyMessage={$at("No matching keys found")}
/>
</div>
</div>
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
<FieldLabel label={$at("Step Duration")} description={$at("Time to wait before executing the next step.")} />
</div>
<div className="flex items-center gap-3">
<SelectMenuBasic

View File

@@ -1,6 +1,7 @@
import { Button } from "@components/Button";
import { cx } from "@/cva.config";
import { AvailableSidebarViews } from "@/hooks/stores";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function SidebarHeader({
title,
@@ -9,6 +10,7 @@ export default function SidebarHeader({
title: string;
setSidebarView: (view: AvailableSidebarViews | null) => void;
}) {
const { $at }= useReactAt();
return (
<div className="flex items-center justify-between border-b border-b-slate-800/20 bg-white px-4 py-1.5 font-semibold text-black dark:bg-slate-900 dark:border-b-slate-300/20">
<div className="min-w-0" style={{ flex: 1 }}>
@@ -17,7 +19,7 @@ export default function SidebarHeader({
<Button
size="XS"
theme="blank"
text="Hide"
text={$at("Hide")}
LeadingIcon={({ className }) => (
<svg
className={cx(className, "rotate-180")}

View File

@@ -22,6 +22,7 @@ const USBStateMap: Record<USBStates, string> = {
attached: "Connecting",
addressed: "Connecting",
"not attached": "Disconnected",
default: "Disconnected",
suspended: "Low power mode",
};
@@ -57,6 +58,13 @@ export default function USBStateStatus({
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
default: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
suspended: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />

View File

@@ -10,6 +10,7 @@ import { SelectMenuBasic } from "./SelectMenuBasic";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import Fieldset from "./Fieldset";
import { useUsbEpModeStore, useAudioModeStore } from "../hooks/stores";
import {useReactAt} from 'i18n-auto-extractor/react'
export interface UsbDeviceConfig {
keyboard: boolean;
@@ -29,20 +30,8 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = {
audio: true,
};
const usbEpOptions = [
{ value: "uac", label: "USB Audio Card"},
{ value: "mtp", label: "Media Transfer Protocol"},
{ value: "disabled", label: "Disabled"},
]
const audioModeOptions = [
{ value: "disabled", label: "Disabled"},
{ value: "usb", label: "USB"},
//{ value: "hdmi", label: "HDMI"},
]
export function UsbEpModeSetting() {
const { $at }= useReactAt();
const usbEpMode = useUsbEpModeStore(state => state.usbEpMode)
const setUsbEpMode = useUsbEpModeStore(state => state.setUsbEpMode)
@@ -110,6 +99,7 @@ export function UsbEpModeSetting() {
notifications.success(`Audio Mode set to ${mode}.It takes effect after refreshing the page`);
setAudioMode(mode);
window.location.reload();
});
};
@@ -132,7 +122,9 @@ export function UsbEpModeSetting() {
audio: false,
mtp: true,
})
handleAudioModeChange("disabled");
if (audioMode !== "disabled") {
handleAudioModeChange("disabled");
}
setUsbEpMode("mtp");
} else {
handleUsbConfigChange({
@@ -140,11 +132,13 @@ export function UsbEpModeSetting() {
audio: false,
mtp: false,
})
handleAudioModeChange("disabled");
if (audioMode !== "disabled") {
handleAudioModeChange("disabled");
}
setUsbEpMode("disabled");
}
},
[handleUsbConfigChange, usbDeviceConfig],
[handleUsbConfigChange, usbDeviceConfig, audioMode],
);
useEffect(() => {
@@ -162,8 +156,8 @@ export function UsbEpModeSetting() {
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsItem
loading={loading}
title="USB Other Function"
description="Select the active USB function (MTP or UAC)"
title={$at("USB Expansion Function")}
description={$at("Select the active USB function (MTP or UAC)")}
>
<SelectMenuBasic
size="SM"
@@ -171,7 +165,11 @@ export function UsbEpModeSetting() {
value={usbEpMode}
fullWidth
onChange={handleUsbEpModeChange}
options={usbEpOptions}
options={[
{ value: "uac", label: $at("UAC(USB Audio Card)")},
{ value: "mtp", label: $at("MTP(Media Transfer Protocol)")},
{ value: "disabled", label: $at("Disabled")},
]}
/>
</SettingsItem>
@@ -186,7 +184,11 @@ export function UsbEpModeSetting() {
size="SM"
label=""
value={audioMode}
options={audioModeOptions}
options={[
{ value: "disabled", label: $at("Disabled")},
{ value: "usb", label: $at("USB")},
//{ value: "hdmi", label: "HDMI"},
]}
onChange={e => handleAudioModeChange(e.target.value)}
/>
</SettingsItem>

View File

@@ -11,6 +11,7 @@ import LoadingSpinner from "@components/LoadingSpinner";
import Card, { GridCard } from "@components/Card";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import {useReactAt} from 'i18n-auto-extractor/react'
interface OverlayContentProps {
readonly children: React.ReactNode;
@@ -30,6 +31,7 @@ interface LoadingOverlayProps {
}
export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
const { $at } = useReactAt();
return (
<AnimatePresence>
{show && (
@@ -49,7 +51,7 @@ export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
Loading video stream...
{$at("Loading video stream...")}
</p>
</div>
</OverlayContent>
@@ -102,6 +104,7 @@ export function ConnectionFailedOverlay({
show,
setupPeerConnection,
}: ConnectionErrorOverlayProps) {
const { $at } = useReactAt();
return (
<AnimatePresence>
{show && (
@@ -121,26 +124,26 @@ export function ConnectionFailedOverlay({
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<h2 className="text-xl font-bold">{$at("Connection Issue Detected")}</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
<li>{$at("Verify that the device is powered on and properly connected")}</li>
<li>{$at("Check all cable connections for any loose or damaged wires")}</li>
<li>{$at("Ensure your network connection is stable and active")}</li>
<li>{$at("Try restarting both the device and your computer")}</li>
</ul>
</div>
<div className="flex items-center gap-x-2">
<LinkButton
to={"https://wiki.luckfox.com/intro"}
theme="primary"
text="Troubleshooting Guide"
text={$at("Troubleshooting Guide")}
TrailingIcon={ArrowRightIcon}
size="SM"
/>
<Button
onClick={() => setupPeerConnection()}
LeadingIcon={ArrowPathIcon}
text="Try again"
text={$at("Try again")}
size="SM"
theme="light"
/>
@@ -162,6 +165,7 @@ interface PeerConnectionDisconnectedOverlay {
export function PeerConnectionDisconnectedOverlay({
show,
}: PeerConnectionDisconnectedOverlay) {
const { $at } = useReactAt();
return (
<AnimatePresence>
{show && (
@@ -181,12 +185,12 @@ export function PeerConnectionDisconnectedOverlay({
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<h2 className="text-xl font-bold">{$at("Connection Issue Detected")}</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
<li>{$at("Verify that the device is powered on and properly connected")}</li>
<li>{$at("Check all cable connections for any loose or damaged wires")}</li>
<li>{$at("Ensure your network connection is stable and active")}</li>
<li>{$at("Try restarting both the device and your computer")}</li>
</ul>
</div>
<div className="flex items-center gap-x-2">
@@ -194,7 +198,7 @@ export function PeerConnectionDisconnectedOverlay({
<div className="flex items-center gap-x-2 p-4">
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
<p className="text-sm text-slate-700 dark:text-slate-300">
Retrying connection...
{$at("Retrying connection...")}
</p>
</div>
</Card>
@@ -217,7 +221,7 @@ interface HDMIErrorOverlayProps {
export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
const isNoSignal = hdmiState === "no_signal";
const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range";
const { $at }= useReactAt();
const [send] = useJsonRpc();
const onSendUsbWakeupSignal = useCallback(() => {
send("sendUsbWakeupSignal", {}, resp => {
@@ -250,25 +254,24 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<h2 className="text-xl font-bold">{$at("No HDMI signal detected.")}</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure the HDMI cable securely connected at both ends</li>
<li>{$at("Ensure the HDMI cable securely connected at both ends")}</li>
<li>
Ensure source device is powered on and outputting a signal
{$at("Ensure source device is powered on and outputting a signal")}
</li>
<li>
If using an adapter, ensure it&apos;s compatible and functioning
correctly
{$at("If using an adapter, ensure it's compatible and functioning correctly")}
</li>
<li>
Ensure source device is not in sleep mode and outputting a signal
{$at("Ensure source device is not in sleep mode and outputting a signal")}
</li>
</ul>
</div>
<div>
<Button
theme="light"
text="Try Wakeup"
text={$at("Try Wakeup")}
TrailingIcon={LuView}
size="SM"
onClick={onSendUsbWakeupSignal}
@@ -278,7 +281,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<LinkButton
to={"https://wiki.luckfox.com/intro"}
theme="light"
text="Learn more"
text={$at("Learn more")}
TrailingIcon={ArrowRightIcon}
size="SM"
/>
@@ -311,9 +314,9 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li>
<li>{$at("A loose or faulty HDMI connection")}</li>
<li>{$at("Incompatible resolution or refresh rate settings")}</li>
<li>{$at("Issues with the source device's HDMI output")}</li>
</ul>
</div>
<div>
@@ -345,6 +348,7 @@ export function NoAutoplayPermissionsOverlay({
show,
onPlayClick,
}: NoAutoplayPermissionsOverlayProps) {
const { $at }= useReactAt();
return (
<AnimatePresence>
{show && (
@@ -376,7 +380,7 @@ export function NoAutoplayPermissionsOverlay({
</div>
<div className="text-xs text-slate-600 dark:text-slate-400">
Please adjust browser settings to enable autoplay
{$at("Please adjust browser settings to enable autoplay")}
</div>
</div>
</div>
@@ -392,6 +396,7 @@ interface PointerLockBarProps {
}
export function PointerLockBar({ show }: PointerLockBarProps) {
const { $at }= useReactAt();
return (
<AnimatePresence mode="wait">
{show ? (
@@ -408,7 +413,7 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
<div className="flex items-center space-x-2">
<BsMouseFill className="h-4 w-4 text-blue-700 dark:text-blue-500" />
<span className="text-sm text-black dark:text-white">
Click on the video to enable mouse control
{$at("Click on the video to enable mouse control")}
</span>
</div>
</div>

View File

@@ -16,6 +16,7 @@ import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
import {useReactAt} from 'i18n-auto-extractor/react'
export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
@@ -26,6 +27,7 @@ const AttachIcon = ({ className }: { className?: string }) => {
};
function KeyboardWrapper() {
const { $at }= useReactAt();
const [layoutName, setLayoutName] = useState("default");
const keyboardRef = useRef<HTMLDivElement>(null);
@@ -244,27 +246,27 @@ function KeyboardWrapper() {
<Button
size="XS"
theme="light"
text="Detach"
text={$at("Detach")}
onClick={() => setShowAttachedVirtualKeyboard(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
text={$at("Attach")}
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
{$at("Virtual Keyboard")}
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
text={$at("Hide")}
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
/>

View File

@@ -6,14 +6,15 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useEffect, useState } from "react";
import notifications from "@/notifications";
import { cx } from "@/cva.config";
import {useReactAt} from 'i18n-auto-extractor/react'
interface IOSettings {
io0Status: boolean;
io1Status: boolean;
}
export function IOControl() {
const { $at }= useReactAt();
const [send] = useJsonRpc();
const [settings, setSettings] = useState<IOSettings>({
io0Status: true,
@@ -49,8 +50,8 @@ export function IOControl() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="IO Control"
description="Configure your io control settings"
title={$at("IO Control")}
description={$at("Configure your io control settings")}
/>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
@@ -68,7 +69,7 @@ export function IOControl() {
size="SM"
theme="primary"
LeadingIcon={LuSun}
text="High"
text={$at("Up")}
onClick={() => {
handleSettingChange("io0Status", true);
}}
@@ -77,7 +78,7 @@ export function IOControl() {
size="SM"
theme="primary"
LeadingIcon={LuSunset}
text="Low"
text={$at("Down")}
onClick={() => {
handleSettingChange("io0Status", false);
}}
@@ -98,7 +99,7 @@ export function IOControl() {
size="SM"
theme="primary"
LeadingIcon={LuSun}
text="High"
text={$at("Up")}
onClick={() => {
handleSettingChange("io1Status", true);
}}
@@ -107,7 +108,7 @@ export function IOControl() {
size="SM"
theme="primary"
LeadingIcon={LuSunset}
text="Low"
text={$at("Down")}
onClick={() => {
handleSettingChange("io1Status", false);
}}

View File

@@ -8,6 +8,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { useUiStore } from "@/hooks/stores";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import {useReactAt} from 'i18n-auto-extractor/react'
interface SerialSettings {
baudRate: string;
@@ -17,6 +18,7 @@ interface SerialSettings {
}
export function SerialConsole() {
const { $at }= useReactAt();
const [send] = useJsonRpc();
const [settings, setSettings] = useState<SerialSettings>({
baudRate: "9600",
@@ -54,8 +56,8 @@ export function SerialConsole() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Serial Console"
description="Configure your serial console settings"
title={$at("Serial Console")}
description={$at("Configure your serial console settings")}
/>
<Card className="animate-fadeIn opacity-0">
@@ -66,7 +68,7 @@ export function SerialConsole() {
size="SM"
theme="primary"
LeadingIcon={LuTerminal}
text="Open Console"
text={$at("Open Console")}
onClick={() => {
setTerminalType("serial");
console.log("Opening serial console with settings: ", settings);
@@ -77,7 +79,7 @@ export function SerialConsole() {
{/* Settings */}
<div className="grid grid-cols-2 gap-4">
<SelectMenuBasic
label="Baud Rate"
label={$at("Baud Rate")}
options={[
{ label: "1200", value: "1200" },
{ label: "2400", value: "2400" },
@@ -93,7 +95,7 @@ export function SerialConsole() {
/>
<SelectMenuBasic
label="Data Bits"
label={$at("Data Bits")}
options={[
{ label: "8", value: "8" },
{ label: "7", value: "7" },
@@ -103,7 +105,7 @@ export function SerialConsole() {
/>
<SelectMenuBasic
label="Stop Bits"
label={$at("Stop Bits")}
options={[
{ label: "1", value: "1" },
{ label: "1.5", value: "1.5" },
@@ -114,7 +116,7 @@ export function SerialConsole() {
/>
<SelectMenuBasic
label="Parity"
label={$at("Parity")}
options={[
{ label: "None", value: "none" },
{ label: "Even", value: "even" },

View File

@@ -8,6 +8,7 @@ import { SerialConsole } from "@components/extensions/SerialConsole";
import { IOControl } from "@components/extensions/IOControl";
import { Button } from "@components/Button";
import notifications from "@/notifications";
import {useReactAt} from 'i18n-auto-extractor/react'
interface Extension {
id: string;
@@ -34,6 +35,7 @@ const AVAILABLE_EXTENSIONS: Extension[] = [
export default function ExtensionPopover() {
const [send] = useJsonRpc();
const [activeExtension, setActiveExtension] = useState<Extension | null>(null);
const { $at }= useReactAt();
// Load active extension on component mount
useEffect(() => {
@@ -92,7 +94,7 @@ export default function ExtensionPopover() {
<Button
size="SM"
theme="light"
text="Quit"
text={$at("Quit")}
onClick={() => handleSetActiveExtension(null)}
/>
</div>
@@ -101,8 +103,8 @@ export default function ExtensionPopover() {
// Extensions List View
<div className="space-y-4">
<SettingsPageHeader
title="Extensions"
description="Load and manage your extensions"
title={$at("Extensions")}
description={$at("Load and manage your extensions")}
/>
<Card className="animate-fadeIn opacity-0" >
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
@@ -122,7 +124,7 @@ export default function ExtensionPopover() {
<Button
size="XS"
theme="light"
text="Load"
text={$at("Load")}
onClick={() => handleSetActiveExtension(extension)}
/>
</div>

View File

@@ -24,8 +24,10 @@ import notifications from "@/notifications";
import { SelectMenuBasic } from "../SelectMenuBasic";
import { SettingsItem } from "../../routes/devices.$id.settings";
import { UsbDeviceConfig } from "@components/UsbEpModeSetting";
import {useReactAt} from 'i18n-auto-extractor/react'
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const { $at }= useReactAt();
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc();
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
@@ -113,10 +115,10 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div>
<div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No mounted media
{$at("No mounted media")}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a file to get started
{$at("Add a file to get started")}
</p>
</div>
</div>
@@ -197,7 +199,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">
Mounted from KVM Storage
{$at("Mounted from KVM storage")}
</h3>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(path, 50)}
@@ -231,12 +233,12 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="h-full space-y-4">
<div className="space-y-4">
<SettingsPageHeader
title="Virtual Media"
description="Mount an image to boot from or install an operating system."
title={$at("Virtual Media")}
description={$at("Mount an image to boot from or install an operating system.")}
/>
<SettingsItem
title="USB Storage Mode"
title={$at("USB Storage Mode")}
description=""
>
<SelectMenuBasic
@@ -245,7 +247,9 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
value={usbStorageMode}
fullWidth
onChange={(e) => handleUsbStorageModeChange(e.target.value)}
options={usbStorageModeOptions}
options={[
{ value: "ums", label: "UMS"},
{ value: "mtp", label: "MTP"},]}
/>
</SettingsItem>
@@ -283,7 +287,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
{remoteVirtualMediaState ? (
<div className="flex select-none items-center justify-between text-xs">
<div className="select-none text-white dark:text-slate-300">
<span>Mounted as</span>{" "}
<span>{$at("Mounted as")}</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span>
@@ -293,7 +297,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button
size="SM"
theme="blank"
text="Close"
text={$at("Close")}
onClick={() => {
close();
}}
@@ -301,7 +305,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button
size="SM"
theme="light"
text="Unmount"
text={$at("Unmount")}
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
@@ -357,7 +361,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div>
<div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
The MTP function has not been activated.
{$at("The MTP function has not been activated.")}
</h3>
</div>
</div>
@@ -384,7 +388,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button
size="SM"
theme="blank"
text="Close"
text={$at("Close")}
onClick={() => {
close();
}}
@@ -392,7 +396,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button
size="SM"
theme="primary"
text="Add New Media"
text={$at("Add New Media")}
onClick={() => {
setModalView("mode");
navigateTo("/mount");
@@ -413,7 +417,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button
size="SM"
theme="blank"
text="Close"
text={$at("Close")}
onClick={() => {
close();
}}
@@ -421,7 +425,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Button
size="SM"
theme="primary"
text="Manager"
text={$at("Manager")}
onClick={() => {
setModalView("mode");
navigateTo("/mtp");

View File

@@ -12,6 +12,7 @@ import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/
import { keys, modifiers } from "@/keyboardMappings";
import { layouts, chars } from "@/keyboardLayouts";
import notifications from "@/notifications";
import {useReactAt} from 'i18n-auto-extractor/react'
const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier };
@@ -38,6 +39,7 @@ export default function PasteModal() {
const setKeyboardLayout = useSettingsStore(
state => state.setKeyboardLayout,
);
const { $at } = useReactAt();
// this ensures we always get the original en_US if it hasn't been set yet
const safeKeyboardLayout = useMemo(() => {
@@ -119,8 +121,8 @@ export default function PasteModal() {
<div className="h-full space-y-4">
<div className="space-y-4">
<SettingsPageHeader
title="Paste text"
description="Paste text from your client to the remote host"
title={$at("Paste text")}
description={$at("Paste text from your client to the remote host")}
/>
<div
@@ -134,7 +136,7 @@ export default function PasteModal() {
<div className="w-full" onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
<TextAreaWithLabel
ref={TextAreaRef}
label="Paste from host"
label={$at("Paste from host")}
rows={4}
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
@@ -166,8 +168,7 @@ export default function PasteModal() {
<div className="mt-2 flex items-center gap-x-2">
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400">
The following characters won&apos;t be pasted:{" "}
{invalidChars.join(", ")}
{$at("The following characters will not be pasted:")} {invalidChars.join(", ")}
</span>
</div>
)}
@@ -175,7 +176,7 @@ export default function PasteModal() {
</div>
<div className="space-y-4">
<p className="text-xs text-slate-600 dark:text-slate-400">
Sending text using keyboard layout: {layouts[safeKeyboardLayout]}
{$at("Sending text using keyboard layout:")} {layouts[safeKeyboardLayout]}
</p>
</div>
</div>
@@ -192,7 +193,7 @@ export default function PasteModal() {
<Button
size="SM"
theme="blank"
text="Cancel"
text={$at("Cancel")}
onClick={() => {
onCancelPasteMode();
close();
@@ -201,7 +202,7 @@ export default function PasteModal() {
<Button
size="SM"
theme="primary"
text="Confirm Paste"
text={$at("Confirm paste")}
onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft}
/>

View File

@@ -3,6 +3,7 @@ import { LuPlus, LuArrowLeft } from "react-icons/lu";
import { InputFieldWithLabel } from "@/components/InputField";
import { Button } from "@/components/Button";
import {useReactAt} from 'i18n-auto-extractor/react'
interface AddDeviceFormProps {
onAddDevice: (name: string, macAddress: string) => void;
@@ -22,6 +23,7 @@ export default function AddDeviceForm({
const nameInputRef = useRef<HTMLInputElement>(null);
const macInputRef = useRef<HTMLInputElement>(null);
const { $at }= useReactAt();
return (
<div className="space-y-4">
@@ -35,7 +37,7 @@ export default function AddDeviceForm({
<InputFieldWithLabel
ref={nameInputRef}
placeholder="Plex Media Server"
label="Device Name"
label={ $at("Device Name") }
required
onChange={e => {
setIsDeviceNameValid(e.target.validity.valid);
@@ -46,7 +48,7 @@ export default function AddDeviceForm({
<InputFieldWithLabel
ref={macInputRef}
placeholder="00:b0:d0:63:c2:26"
label="MAC Address"
label={ $at("MAC Address") }
onKeyUp={e => e.stopPropagation()}
required
pattern="^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$"
@@ -82,14 +84,14 @@ export default function AddDeviceForm({
<Button
size="SM"
theme="light"
text="Back"
text={ $at("Back") }
LeadingIcon={LuArrowLeft}
onClick={() => setShowAddForm(false)}
/>
<Button
size="SM"
theme="primary"
text="Save Device"
text={$at("Save Device")}
disabled={!isDeviceNameValid || !isMacAddressValid}
onClick={() => {
const deviceName = nameInputRef.current?.value || "";

View File

@@ -3,6 +3,7 @@ import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
import { Button } from "@/components/Button";
import Card from "@/components/Card";
import { FieldError } from "@/components/InputField";
import {useReactAt} from 'i18n-auto-extractor/react'
export interface StoredDevice {
name: string;
@@ -26,6 +27,8 @@ export default function DeviceList({
onCancelWakeOnLanModal,
setShowAddForm,
}: DeviceListProps) {
const { $at }= useReactAt();
return (
<div className="space-y-4">
<Card className="animate-fadeIn opacity-0">
@@ -46,7 +49,7 @@ export default function DeviceList({
<Button
size="XS"
theme="light"
text="Wake"
text={ $at("Wake") }
LeadingIcon={LuSend}
onClick={() => onSendMagicPacket(device.macAddress)}
/>
@@ -55,7 +58,7 @@ export default function DeviceList({
theme="danger"
LeadingIcon={LuTrash2}
onClick={() => onDeleteDevice(index)}
aria-label="Delete device"
aria-label={ $at("Delete device") }
/>
</div>
</div>
@@ -69,11 +72,11 @@ export default function DeviceList({
animationDelay: "0.2s",
}}
>
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
<Button size="SM" theme="blank" text={$at("Close")} onClick={onCancelWakeOnLanModal} />
<Button
size="SM"
theme="primary"
text="Add New Device"
text={ $at("Add New Device") }
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>

View File

@@ -3,6 +3,7 @@ import { LuPlus } from "react-icons/lu";
import Card from "@/components/Card";
import { Button } from "@/components/Button";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function EmptyStateCard({
onCancelWakeOnLanModal,
@@ -11,6 +12,8 @@ export default function EmptyStateCard({
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}) {
const { $at }= useReactAt();
return (
<div className="select-none space-y-4">
<Card className="animate-fadeIn opacity-0">
@@ -25,10 +28,10 @@ export default function EmptyStateCard({
</Card>
</div>
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No devices added
{ $at("No devices added") }
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a device to start using Wake-on-LAN
{ $at("Add a device to start using Wake-on-LAN") }
</p>
</div>
</div>
@@ -41,11 +44,11 @@ export default function EmptyStateCard({
animationDelay: "0.2s",
}}
>
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
<Button size="SM" theme="blank" text={ $at("Close") } onClick={onCancelWakeOnLanModal} />
<Button
size="SM"
theme="primary"
text="Add New Device"
text={ $at("Add new device") }
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>

View File

@@ -11,6 +11,7 @@ import notifications from "@/notifications";
import EmptyStateCard from "./EmptyStateCard";
import DeviceList, { StoredDevice } from "./DeviceList";
import AddDeviceForm from "./AddDeviceForm";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function WakeOnLanModal() {
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
@@ -23,6 +24,7 @@ export default function WakeOnLanModal() {
const close = useClose();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
const { $at } = useReactAt();
const onCancelWakeOnLanModal = useCallback(() => {
close();
@@ -117,8 +119,8 @@ export default function WakeOnLanModal() {
<div className="grid h-full grid-rows-(--grid-headerBody)">
<div className="space-y-4">
<SettingsPageHeader
title="Wake On USB"
description="Send a Signal to wake up the device connected via USB."
title={$at("Wake On USB")}
description={$at("Send a Signal to wake up the device connected via USB.")}
/>
<div
@@ -131,14 +133,14 @@ export default function WakeOnLanModal() {
<Button
size="SM"
theme="primary"
text="Wake"
text={$at("Wake")}
onClick={onSendUsbWakeupSignal}
/>
</div>
<SettingsPageHeader
title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device."
title={$at("Wake On LAN")}
description={$at("Send a Magic Packet to wake up a remote device.")}
/>
{showAddForm ? (

View File

@@ -4,6 +4,7 @@ import SidebarHeader from "@/components/SidebarHeader";
import { GridCard } from "@/components/Card";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@/components/StatChart";
import {useReactAt} from 'i18n-auto-extractor/react'
function createChartArray<T, K extends keyof T>(
stream: Map<number, T>,
@@ -62,6 +63,7 @@ export default function ConnectionStatsSidebar() {
const peerConnection = useRTCStore(state => state.peerConnection);
const mediaStream = useRTCStore(state => state.mediaStream);
const sidebarView = useUiStore(state => state.sidebarView);
const { $at }= useReactAt();
useInterval(function collectWebRTCStats() {
(async () => {
@@ -100,7 +102,7 @@ export default function ConnectionStatsSidebar() {
return (
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
<SidebarHeader title="Connection State" setSidebarView={setSidebarView} />
<SidebarHeader title={$at("Connection Stats")} setSidebarView={setSidebarView} />
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
<div className="space-y-4">
{/*
@@ -112,10 +114,10 @@ export default function ConnectionStatsSidebar() {
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Packets Lost
{$at("Packet Loss")}
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Number of data packets lost during transmission.
{$at("Number of packets lost during transmission")}
</p>
</div>
<GridCard>
@@ -141,10 +143,10 @@ export default function ConnectionStatsSidebar() {
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Round-Trip Time
{$at("Round Trip Time")}
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Time taken for data to travel from source to destination and back
{$at("Time taken for data to travel from source to destination and back")}
</p>
</div>
<GridCard>
@@ -178,10 +180,10 @@ export default function ConnectionStatsSidebar() {
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Jitter
{$at("Jitter")}
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Variation in packet delay, affecting video smoothness.{" "}
{$at("Variation in packet delay, affecting video smoothness.")}{" "}
</p>
</div>
<GridCard>
@@ -208,10 +210,10 @@ export default function ConnectionStatsSidebar() {
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Frames per second
{$at("Frame per second")}
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Number of video frames displayed per second.
{$at("Number of video frames displayed per second.")}
</p>
</div>
<GridCard>

View File

@@ -300,6 +300,9 @@ export const useVideoStore = create<VideoState>(set => ({
export type KeyboardLedSync = "auto" | "browser" | "host";
interface SettingsState {
language: string;
setLanguage: (language: string) => void;
isCursorHidden: boolean;
setCursorVisibility: (enabled: boolean) => void;
@@ -355,6 +358,9 @@ interface SettingsState {
export const useSettingsStore = create(
persist<SettingsState>(
set => ({
language: "en",
setLanguage: language => set({ language }),
isCursorHidden: false,
setCursorVisibility: enabled => set({ isCursorHidden: enabled }),
@@ -516,7 +522,7 @@ export interface HidState {
isPasteModeEnabled: boolean;
setPasteModeEnabled: (enabled: boolean) => void;
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed";
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed" | "default";
setUsbState: (state: HidState["usbState"]) => void;
}

432
ui/src/locales/en.json Normal file
View File

@@ -0,0 +1,432 @@
{
"1281764038": "Unmount",
"2188692750": "Send a Signal to wake up the device connected via USB.",
"3899981764": "You can only add a maximum of",
"514d8a494f": "Terminal",
"ef4f580086": "Paste Text",
"d1d83fc474": "Virtual Media",
"688761c83e": "Wake",
"b39b8c60c9": "Virtual Keyboard",
"5e2f5f3c24": "Extensions",
"c2cc7082a8": "Connection",
"f4f70727dc": "Settings",
"0829ea6734": "Fullscreen",
"200a8e5c7c": "DHCP Lease Information",
"5b8c99dad1": "IP Address",
"3026b7b5e7": "Subnet Mask",
"4fe46eb6b5": "DNS Servers",
"ff6c0e9569": "Broadcast Address",
"eae639a700": "Domain",
"b2af759e7b": "NTP Servers",
"c8f4b8c435": "Hostname",
"f4bb39e634": "Gateways",
"1ebb4adb10": "DHCP Server",
"1983b808cc": "Lease Expiry",
"eefc625099": "Boot Next Server",
"ae601d8d44": "Boot Server Name",
"079aa57382": "Boot File",
"f9099fc033": "Renew DHCP Lease",
"b5a4b64b2a": "Resolution",
"ef6876cbd0": "Video Size",
"61cf851020": "Pointer",
"aa858e410f": "Last Move",
"7e146fcb5f": "USB State",
"dc24602f95": "HDMI State",
"a73e6bf278": "Keys",
"6e188f5984": "IPv6 Information",
"cfdffa4fc7": "Link-local",
"b03d09d6a5": "Connect to KVM",
"35f6b89cd3": "Troubleshoot Connection",
"904a830405": "Rename",
"5ba79d5e6e": "Deregister from cloud",
"ff842c1e87": "Macro Name",
"f3a29486be": "Steps",
"a62cdc8119": "Keys/modifiers executed in sequence with a delay between each step.",
"2a9b9ff9a9": "Add Step",
"2ffe4e7732": "max",
"4c33dd4e81": "steps per macro.",
"575f5f86d2": "Saving...",
"c9cc8cce24": "Save",
"ea4788705e": "Cancel",
"f2a6c498fb": "Delete",
"ab38583cb2": "Modifiers",
"8321e79c27": "Maximum",
"b5229f5de5": "keys per step.",
"446e7296d8": "Max keys reached",
"66e186d6ec": "Search for key...",
"a242534611": "No matching keys found",
"5d174694e2": "Step Duration",
"d2f7d335ea": "Time to wait before executing the next step.",
"62a5e49088": "Hide",
"b34e855501": "USB Expansion Function",
"52443ccfe9": "Select the active USB function (MTP or UAC)",
"46a78e6e5e": "UAC(USB Audio Card)",
"4988092f49": "MTP(Media Transfer Protocol)",
"b9f5c797eb": "Disabled",
"7aca5ec618": "USB",
"0ed17cebde": "Loading video stream...",
"c5bac5200c": "Connection Issue Detected",
"9f5f92d1c6": "Verify that the device is powered on and properly connected",
"f32a1d11ff": "Check all cable connections for any loose or damaged wires",
"705bc591cb": "Ensure your network connection is stable and active",
"7e081e9805": "Try restarting both the device and your computer",
"077355ceb7": "Troubleshooting Guide",
"f915a95e60": "Try again",
"3a0cb9077d": "Retrying connection...",
"6f3f64de27": "No HDMI signal detected.",
"a90c4aa86f": "Ensure the HDMI cable securely connected at both ends",
"6fd1357371": "Ensure source device is powered on and outputting a signal",
"3ea74f7e5e": "If using an adapter, ensure it's compatible and functioning correctly",
"54b1e31a57": "Ensure source device is not in sleep mode and outputting a signal",
"693a670a35": "Try Wakeup",
"d59048f21f": "Learn more",
"6eab13ac29": "A loose or faulty HDMI connection",
"4fb5641c04": "Incompatible resolution or refresh rate settings",
"e733943b71": "Issues with the source device's HDMI output",
"72c63569ea": "Please adjust browser settings to enable autoplay",
"8fc3022cab": "Click on the video to enable mouse control",
"f200763a0d": "Detach",
"7193518e6f": "Attach",
"703703879d": "IO Control",
"4ae517d1d2": "Configure your io control settings",
"258f49887e": "Up",
"08a38277b0": "Down",
"dd110018d2": "Serial Console",
"56b9d916a6": "Configure your serial console settings",
"f1ea2ce47c": "Open Console",
"83657602de": "Baud Rate",
"3167aad8bf": "Data Bits",
"fa6b98eef0": "Stop Bits",
"a91d6e31a7": "Parity",
"0d82790b06": "Quit",
"6aa89499ac": "Load and manage your extensions",
"f19dbf2edb": "Load",
"72d29217aa": "No mounted media",
"42047fa6f8": "Add a file to get started",
"0a01e80566": "Mounted from KVM storage",
"0c2cd2d9d0": "Mount an image to boot from or install an operating system.",
"36db084898": "USB Storage Mode",
"2d74461fca": "Mounted as",
"d3d2e61733": "Close",
"435a349b62": "The MTP function has not been activated.",
"7d388e3d0d": "Add New Media",
"ae94be3cd5": "Manager",
"5fe0273ec6": "Paste text",
"2a5a0639a5": "Paste text from your client to the remote host",
"6d161c8084": "Paste from host",
"604c45fbf2": "The following characters will not be pasted:",
"a7eb9efa0b": "Sending text using keyboard layout:",
"2593d0a3d5": "Confirm paste",
"92394ac496": "Device Name",
"3121f87028": "MAC Address",
"0557fa923d": "Back",
"74b68172da": "Save Device",
"45efa881cf": "Delete device",
"0ebb52afb9": "Add New Device",
"a711459a7f": "No devices added",
"1c1365f038": "Add a device to start using Wake-on-LAN",
"da6f38a499": "Add new device",
"ec7f891851": "Wake On USB",
"5e2f88b89c": "Wake On LAN",
"a4e56eaefa": "Send a Magic Packet to wake up a remote device.",
"b349fc74c3": "Connection Stats",
"211c634c8f": "Packet Loss",
"ccdc89b369": "Number of packets lost during transmission",
"8937469f52": "Round Trip Time",
"442786d82f": "Time taken for data to travel from source to destination and back",
"431066a2b6": "Jitter",
"9aab28af69": "Variation in packet delay, affecting video smoothness.",
"75316ccce3": "Frame per second",
"75a4e60fe7": "Number of video frames displayed per second.",
"90c0b28ff9": "Virtual Media Source",
"7a55f3fa7c": "Choose how you want to mount your virtual media",
"a0bfb8e59e": "Continue",
"eace16d66c": "Mount",
"be14908f8f": "Mount from KVM Storage",
"0f9559529a": "Select the image you want to mount from the KVM storage",
"49ee6cd125": "Automatically mount system_info.img",
"3fba468b57": "Mount system_info.img automatically when the KVM startup",
"0db5b7f91d": "No images available",
"e68d09d335": "Upload an image to start virtual media mounting.",
"e5b17a617c": "Upload a new image",
"68d049d3be": "Are you sure you want to delete ",
"dd1f775e44": "Previous",
"10ac3d0425": "Next",
"fdf2bc5fea": "Available space",
"1aec9c5d06": "used",
"aa2d6e4f57": "free",
"3ec87b0bdf": "Mount from KVM MicroSD Card",
"b9880079d5": "Select an image to mount from the KVM storage",
"fecda15c75": "No SD card detected",
"2e95d8b66d": "SD card mount failed",
"504ca7e4e7": "Please insert an SD card and try again.",
"c153abe154": "Please format the SD card and try again.",
"583caf12dd": "Mount File",
"f75c8978f9": "Available Space",
"ed8f7ee878": "Upload a New Image",
"a5b2c262bc": "Unmount Micro SD Card",
"7648e7a926": "Select an image file to upload to KVM storage",
"69c07f7bee": "Click to select",
"17d6ec6acf": "Click to select a file",
"2d68c75f33": "Supported formats: ISO, IMG",
"3f1c5b0049": "Uploading",
"f287042190": "Uploading...",
"0582cd2fd6": "Calculating...",
"c89e2f6fd0": "Upload Successful",
"fe8d588f34": "Uploaded",
"b27a41db76": "Cancel Upload",
"209667a279": "Back to Overview",
"0521a9c3db": "Continue uploading",
"bf745332f6": "Mount as",
"c708fd4e36": "Shared Folders",
"59eecaa12a": "Select the shared folder that you want to manage",
"1d50425f88": "Manage Shared Folders in KVM Storage",
"1d6d3f1668": "No files",
"93ee55ee88": "Upload a new file",
"09deaf92e5": "Upload a New File",
"bafa44d7f4": "Available Storage",
"d4aa72e4c0": "Manage Shared Folder in KVM Storage",
"a263c70171": "Are you sure you want to download ",
"e7f9b3e161": "Select a file to upload",
"ddeb0eac69": "Do not support directories",
"801ab24683": "Download",
"d6e24388b4": "Continue Uploading",
"bf733d8a93": "Access",
"70e23e7d6f": "Manage the Access Control of the device",
"509820290d": "Local",
"b65477b714": "Manage the mode of local access to the device",
"495ec67387": "HTTPS Mode",
"6bb4d0c8d3": "Configure secure HTTPS access to your device",
"0ced0ab47c": "Self-signed",
"90589c47f0": "Custom",
"303329b9d7": "TLS Certificate",
"34395e2d6c": "Paste your TLS certificate below. For certificate chains, include the entire chain (leaf, intermediate, and root certificates).",
"eb0f48a107": "Certificate",
"7d25ae26f4": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----",
"d2560860c5": "Private Key",
"cac41e667b": "For security reasons, it will not be displayed after saving.",
"199bd6a05e": "-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----",
"387cfe77a5": "Update TLS Settings",
"1083cbc4de": "Authentication Mode",
"d6150fa2c2": "Current mode:",
"7aa025f6e7": "Password protected",
"f8cf7801bd": "No password",
"119a6cb24a": "Disable Protection",
"efd3cc702e": "Enable Password",
"8f1e77e0d2": "Change Password",
"3d0de21428": "Update your device access password",
"f8508f576c": "Remote",
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
"e4abe63a8b": "Connect to TailScale VPN network",
"0b86461350": "TailScale use xEdge server",
"a7d199ad4f": "Login URL:",
"3bef87ee46": "Wait to obtain the Login URL",
"a3060e541f": "Quitting...",
"0baef6200d": "Network IP",
"2faec1f9f8": "Enable",
"761c43fec5": "Connect to ZeroTier VPN network",
"3cc3bd7438": "Connecting...",
"5028274560": "Network ID",
"a2c4bef9fa": "Connect fail, please retry",
"6327b4e59f": "Retry",
"37cfbb7f44": "Enter ZeroTier Network ID",
"718f8fd90c": "Join in",
"18c514b621": "Connect to EasyTier server",
"a3e117fed1": "Network Name",
"5f1ffd341a": "Network Secret",
"8fc22b3dac": "Network Node",
"11a755d598": "Stop",
"ce0be71e33": "Log",
"3119fca100": "Node Info",
"3b92996a28": "Enter EasyTier Network Name",
"a4c4c07b3d": "Enter EasyTier Network Secret",
"7a1920d611": "Default",
"63e0339544": "Enter EasyTier Network Node",
"a6122a65ea": "Start",
"03eb0dbd4f": "Connect to Frp server",
"1bbabcdef3": "Edit frpc.toml",
"9c088a303a": "Enter frpc configuration",
"867cee98fd": "Passwords do not match",
"9864ff9420": "Please enter your old password",
"14a714ab22": "Please enter a new password",
"65b259c5c8": "An error occurred while changing the password",
"b72aabf566": "Please enter your current password",
"2bbdfe1fbb": "An error occurred while disabling the password",
"8f5c014173": "Password Set Successfully",
"733794dd15": "You've successfully set up local device protection. Your device is now secure against unauthorized local access.",
"ded7f3183f": "Password Protection Disabled",
"9e540e52c5": "You've successfully disabled the password protection for local access. Remember, your device is now less secure.",
"b14e23413a": "Password Updated Successfully",
"b0695c9383": "You've successfully changed your local device protection password. Make sure to remember your new password for future access.",
"d28f648684": "Local Device Protection",
"f8ef48c9cc": "Create a password to protect your device from unauthorized local access.",
"ae3bb2a1ac": "New Password",
"d940c35ef0": "Enter a strong password",
"294ec22d2c": "Confirm New Password",
"795a83a075": "Re-enter your password",
"ffa14987a2": "Secure Device",
"8dff1db0cc": "Not Now",
"d8758c7b54": "Disable Local Device Protection",
"5b5ff17559": "Enter your current password to disable local device protection.",
"4bc28f132d": "Current Password",
"a454262ec0": "Enter your current password",
"661b5e9f46": "Change Local Device Password",
"e2e1852630": "Enter your current password and a new password to update your local device protection.",
"f5ca758fa9": "Enter a new strong password",
"74f13549ab": "Re-enter your new password",
"bb78dd7992": "Update Password",
"9b6545e4ce": "Advanced",
"9e1b8a17a2": "Access additional settings for troubleshooting and customization",
"0ce762644e": "Loopback-Only Mode",
"49aea4318d": "Restrict web interface access to localhost only (127.0.0.1)",
"af761a417b": "SSH Access",
"2eec49b1fe": "Add your SSH public key to enable secure remote access to the device",
"96092cbe14": "SSH Public Key",
"3369c97f56": "Enter your SSH public key",
"81bafb2833": "The default SSH user is ",
"7a941a0f87": "Update SSH Key",
"ccf180df6d": "Troubleshooting Mode",
"8a585331c2": "Diagnostic tools and additional controls for troubleshooting and development purposes",
"021fa314ef": "USB Emulation",
"5c43d74dbd": "Control the USB emulation state",
"f6c8ddbadf": "Disable USB Emulation",
"020b92cfbb": "Enable USB Emulation",
"f43c0398a4": "Reset Configuration",
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
"0d784092e8": "Reset Config",
"a1c58e9422": "Appearance",
"d414b664a7": "Customize the look and feel of your KVM interface",
"d721757161": "Theme",
"672ca378ea": "Choose your preferred color theme",
"a45da96d0b": "System",
"9914a0ce04": "Light",
"a18366b217": "Dark",
"0db377921f": "General",
"3845ee1693": "Configure device settings and update preferences",
"34b6cd7517": "Version",
"ac863f346e": "App",
"250be9377e": "App: Loading...",
"a8463aff5c": "System: Loading...",
"f77f38f684": "Check for Updates",
"c5118014fe": "Auto Update",
"551e42b147": "Automatically update the device to the latest version",
"3c02a37996": "Hardware",
"0eee916f73": "Configure display settings and hardware options for your KVM device",
"ac414bc6f0": "Display Orientation",
"b684d709e4": "Set the orientation of the display",
"68dc91274c": "Display Brightness",
"3a3d04592f": "Set the brightness of the display",
"d15305d7a4": "Off",
"28d0edd045": "Low",
"87f8a6ab85": "Medium",
"655d20c1ca": "High",
"3b9e4fb575": "Dim Display After",
"2496239ef1": "Set how long to wait before dimming the display",
"180bd3b9ac": "Turn off Display After",
"4e654cc401": "Period of inactivity before display automatically turns off",
"cfe90cea62": "The display will wake up when the connection state changes, or when touched.",
"45c10605e0": "Time Zone",
"3fbe0eeaca": "Set the time zone for the clock",
"5d5b78699e": "Set",
"38046d6028": "LED-Green Type",
"8dac3b62fa": "Set the type of system status indicated by the LED-Green",
"65b20c6a69": "network-link",
"c4d1012b3e": "network-tx",
"48efe80adc": "network-rx",
"7657bdf983": "kernel-activity",
"ac8fb475d0": "LED-Yellow Type",
"84906abc3d": "Set the type of system status indicated by the LED-Yellow",
"6ce4d85a62": "Keyboard",
"97a30a5a34": "Configure keyboard settings for your device",
"47e02c5a49": "Keyboard layout of target operating system",
"e6273eecd6": "Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in KVM matches the settings in the operating system.",
"233f9ed53c": "LED state synchronization",
"a336facf13": "Synchronize the LED state of the keyboard with the target device",
"06b9281e39": "Auto",
"6d9e6cf689": "Browser Only",
"6716df9c26": "Host Only",
"27ba83a2e3": "Show Pressed Keys",
"6d5132704c": "Display currently pressed keys in the status bar",
"036f3af7f3": "Add New Macro",
"276043dbf0": "Create a new keyboard macro",
"7121578730": "Edit Macro",
"9605ef9593": "Modify your keyboard macro",
"b578818af4": "Delete Macro",
"ffbb410a55": "Deleting",
"3dd2e50646": "Delay Only",
"7dce122004": "Edit",
"05fd7d5b9c": "Are you sure you want to delete",
"784db3951c": "? This action cannot be undone.",
"1294e8c29f": "Deleting...",
"2678009cba": "Keyboard Macros",
"d281352b5c": "Combine keystrokes into a single action for faster workflows.",
"219122c532": "Max Reached",
"b5401e06fc": "Add new macro",
"616d131a3e": "Loading macros...",
"34a4befad8": "Create Your First Macro",
"e50cad5a77": "Combine keystrokes into a single action",
"f2a47c6809": "Mouse",
"7c6596eec1": "Configure cursor behavior and interaction settings for your device",
"a418c76d1b": "Hide Cursor",
"b52b706817": "Hide the cursor when sending mouse movements",
"0eac3a2cd7": "Scroll Throttling",
"ff136d228b": "Reduce the frequency of scroll events",
"43cac00c74": "Jiggler",
"a043cec582": "Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating",
"39e5859fec": "Modes",
"03a221978b": "Choose the mouse input mode",
"b51ca26c6c": "Absolute",
"7a9ea21ac1": "Most convenient",
"2ca9469819": "Relative",
"3df264e35b": "Most Compatible",
"eec89088ee": "Network",
"c2d35aad90": "Configure your network settings",
"6c64add3bf": "Hardware identifier for the network interface",
"e582019c42": "Device identifier on the network. Blank for system default",
"026b233624": "Device domain suffix in mDNS network",
"96da6def5d": "Custom Domain",
"ef64a3770e": "Control mDNS (multicast DNS) operational mode",
"be8226fe0c": "Time synchronization",
"7e06bd28a6": "Configure time synchronization settings",
"d4dccb8ca2": "Save settings",
"8750a898cb": "IPv4 Mode",
"72c2543791": "Configure IPv4 mode",
"94c252be0e": "DHCP Information",
"902f16cd13": "No DHCP lease information available",
"6a802c3684": "IPv6 Mode",
"d29b71c737": "Configure the IPv6 mode",
"d323009843": "No IPv6 addresses configured",
"892eec6b1a": "This will request your DHCP server to assign a new IP address. Your device may lose network connectivity during the process.",
"697b29c12e": "Back to KVM",
"34e2d1989a": "Video",
"2c50ab9cb6": "Configure display settings and EDID for optimal compatibility",
"41d263a988": "Stream Quality",
"95c3a5fa39": "Adjust the quality of the video stream",
"a6c2e30b8e": "Video Enhancement",
"684c75dc2e": "Adjust color settings to make the video output more vibrant and colorful",
"2e3307b9b1": "Saturation",
"e6efb3393e": "Color saturation",
"b25323f997": "Brightness",
"720984e4f0": "Brightness level",
"c63ecd19a0": "Contrast",
"5a31e20e6d": "Contrast level",
"4418069f82": "Reset to Default",
"25793bfcbb": "Adjust the EDID settings for the display",
"34d026e0a9": "Custom EDID",
"b0258b2bdb": "EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments.",
"85a003df9b": "EDID File",
"5c7a666766": "Set Custom EDID",
"7dad0ba758": "Restore to default",
"7303619cd1": "Local Authentication Method",
"2c72360ce8": "Select how you would like to secure your KVM device locally.",
"40735df34e": "You can always change your authentication method later in the settings.",
"9ffd0df7aa": "Set a Password",
"63abf08bec": "Create a strong password to secure your KVM device locally.",
"dc647eb65e": "Password",
"a4a22f26c2": "Enter a password",
"887f7db126": "Confirm Password",
"65f1314580": "Confirm your password",
"af21497286": "Set Password",
"c3f88872d6": "This password will be used to secure your device data and protect against unauthorized access.",
"06a7b3bf6e": "All data remains on your local device."
}

432
ui/src/locales/zh.json Normal file
View File

@@ -0,0 +1,432 @@
{
"1281764038": "卸载",
"2188692750": "发送信号以唤醒通过 USB 连接的设备。",
"3899981764": "你最多只能添加",
"514d8a494f": "终端",
"ef4f580086": "粘贴文本",
"d1d83fc474": "存储",
"688761c83e": "唤醒",
"b39b8c60c9": "虚拟键盘",
"5e2f5f3c24": "扩展",
"c2cc7082a8": "连接",
"f4f70727dc": "设置",
"0829ea6734": "全屏",
"200a8e5c7c": "DHCP 租约信息",
"5b8c99dad1": "IP 地址",
"3026b7b5e7": "子网掩码",
"4fe46eb6b5": "DNS 服务器",
"ff6c0e9569": "广播地址",
"eae639a700": "域名",
"b2af759e7b": "NTP 服务器",
"c8f4b8c435": "主机名",
"f4bb39e634": "网关",
"1ebb4adb10": "DHCP 服务器",
"1983b808cc": "租约到期",
"eefc625099": "启动下一服务器",
"ae601d8d44": "启动服务器名称",
"079aa57382": "启动文件",
"f9099fc033": "重订 DHCP 租约",
"b5a4b64b2a": "分辨率",
"ef6876cbd0": "显示尺寸",
"61cf851020": "指针",
"aa858e410f": "上一次移动",
"7e146fcb5f": "USB 状态",
"dc24602f95": "HDMI 状态",
"a73e6bf278": "按键",
"6e188f5984": "IPv6 信息",
"cfdffa4fc7": "Link-local",
"b03d09d6a5": "连接到 KVM",
"35f6b89cd3": "排查连接问题",
"904a830405": "重命名",
"5ba79d5e6e": "从云端注销",
"ff842c1e87": "宏名称",
"f3a29486be": "步骤",
"a62cdc8119": "按键/修饰符按顺序执行,每一步之间有延迟。",
"2a9b9ff9a9": "添加步骤",
"2ffe4e7732": "最大",
"4c33dd4e81": "每个宏的步骤数。",
"575f5f86d2": "保存中...",
"c9cc8cce24": "保存",
"ea4788705e": "取消",
"f2a6c498fb": "删除",
"ab38583cb2": "修饰符",
"8321e79c27": "最大",
"b5229f5de5": "每步的按键数。",
"446e7296d8": "已达到最大按键数",
"66e186d6ec": "搜索按键...",
"a242534611": "未找到匹配的按键",
"5d174694e2": "步骤持续时间",
"d2f7d335ea": "执行下一步之前等待的时间。",
"62a5e49088": "隐藏",
"b34e855501": "USB拓展功能",
"52443ccfe9": "选择活动的 USB 功能MTP 或 UAC",
"46a78e6e5e": "UACUSB 音频卡)",
"4988092f49": "MTP媒体传输协议",
"b9f5c797eb": "禁用",
"7aca5ec618": "USB",
"0ed17cebde": "正在加载视频流...",
"c5bac5200c": "检测到连接问题",
"9f5f92d1c6": "请确认设备已开机并正确连接",
"f32a1d11ff": "检查所有线缆连接是否松动或损坏",
"705bc591cb": "确保您的网络连接稳定且正常",
"7e081e9805": "尝试重启设备和电脑",
"077355ceb7": "故障排除指南",
"f915a95e60": "重试",
"3a0cb9077d": "正在重试连接...",
"6f3f64de27": "未检测到 HDMI 信号。",
"a90c4aa86f": "确保 HDMI 线缆两端连接牢固",
"6fd1357371": "确保源设备已开机并输出信号",
"3ea74f7e5e": "如果使用适配器,确保其兼容且工作正常",
"54b1e31a57": "确保源设备未处于睡眠模式且正在输出信号",
"693a670a35": "尝试唤醒",
"d59048f21f": "了解更多",
"6eab13ac29": "HDMI 连接松动或故障",
"4fb5641c04": "分辨率或刷新率设置不兼容",
"e733943b71": "源设备 HDMI 输出存在问题",
"72c63569ea": "请调整浏览器设置以启用自动播放",
"8fc3022cab": "点击视频以启用鼠标控制",
"f200763a0d": "分离",
"7193518e6f": "固定",
"703703879d": "IO 控制",
"4ae517d1d2": "配置您的 IO 输出电平状态",
"258f49887e": "高电平",
"08a38277b0": "低电平",
"dd110018d2": "串口控制台",
"56b9d916a6": "配置串口控制台设置",
"f1ea2ce47c": "打开控制台",
"83657602de": "波特率",
"3167aad8bf": "数据位",
"fa6b98eef0": "停止位",
"a91d6e31a7": "校验位",
"0d82790b06": "退出",
"6aa89499ac": "加载并管理您的扩展",
"f19dbf2edb": "加载",
"72d29217aa": "未挂载",
"42047fa6f8": "添加文件以开始使用",
"0a01e80566": "已从 KVM 存储挂载",
"0c2cd2d9d0": "挂载镜像或管理共享文件夹",
"36db084898": "USB 存储模式",
"2d74461fca": "挂载为",
"d3d2e61733": "关闭",
"435a349b62": "MTP 功能尚未激活。",
"7d388e3d0d": "添加新挂载",
"ae94be3cd5": "管理",
"5fe0273ec6": "粘贴文本",
"2a5a0639a5": "将文本从客户端粘贴到远程主机",
"6d161c8084": "从主机粘贴",
"604c45fbf2": "以下字符将不会被粘贴:",
"a7eb9efa0b": "使用键盘布局发送文本:",
"2593d0a3d5": "确认粘贴",
"92394ac496": "设备名称",
"3121f87028": "MAC 地址",
"0557fa923d": "返回",
"74b68172da": "保存设备",
"45efa881cf": "删除设备",
"0ebb52afb9": "添加新设备",
"a711459a7f": "尚未添加设备",
"1c1365f038": "添加设备以开始使用网络唤醒",
"da6f38a499": "添加新设备",
"ec7f891851": "USB 唤醒",
"5e2f88b89c": "网络唤醒",
"a4e56eaefa": "发送魔术包以唤醒远程设备。",
"b349fc74c3": "连接统计",
"211c634c8f": "丢包率",
"ccdc89b369": "传输过程中丢失的数据包数量",
"8937469f52": "往返时间",
"442786d82f": "数据从源到目的地再返回所需的时间",
"431066a2b6": "抖动",
"9aab28af69": "数据包延迟的变化,影响视频流畅度。",
"75316ccce3": "帧率",
"75a4e60fe7": "每秒显示的视频帧数。",
"90c0b28ff9": "虚拟媒体源",
"7a55f3fa7c": "选择您希望挂载虚拟媒体的方式",
"a0bfb8e59e": "继续",
"eace16d66c": "挂载",
"be14908f8f": "从 KVM 存储挂载",
"0f9559529a": "从 KVM 存储中选择要挂载的镜像",
"49ee6cd125": "自动挂载 system_info.img",
"3fba468b57": "KVM 启动时自动挂载 system_info.img",
"0db5b7f91d": "无可用镜像",
"e68d09d335": "上传镜像以开始虚拟媒体挂载。",
"e5b17a617c": "上传新镜像",
"68d049d3be": "您确定要删除 ",
"dd1f775e44": "上一个",
"10ac3d0425": "下一个",
"fdf2bc5fea": "可用空间",
"1aec9c5d06": "已使用",
"aa2d6e4f57": "剩余",
"3ec87b0bdf": "从 KVM MicroSD 卡挂载",
"b9880079d5": "从 KVM 存储中选择镜像",
"fecda15c75": "未检测到 SD 卡",
"2e95d8b66d": "SD 卡挂载失败",
"504ca7e4e7": "请插入 SD 卡并重试。",
"c153abe154": "请格式化 SD 卡并重试。",
"583caf12dd": "挂载文件",
"f75c8978f9": "可用空间",
"ed8f7ee878": "上传新镜像",
"a5b2c262bc": "卸载 Micro SD 卡",
"7648e7a926": "选择要上传到 KVM 存储的镜像文件",
"69c07f7bee": "点击选择",
"17d6ec6acf": "点击选择文件",
"2d68c75f33": "支持的格式ISO、IMG",
"3f1c5b0049": "上传中",
"f287042190": "正在上传...",
"0582cd2fd6": "计算中...",
"c89e2f6fd0": "上传成功",
"fe8d588f34": "已上传",
"b27a41db76": "取消上传",
"209667a279": "返回概览",
"0521a9c3db": "继续上传",
"bf745332f6": "挂载为",
"c708fd4e36": "共享文件夹",
"59eecaa12a": "选择要管理的共享文件夹",
"1d50425f88": "管理 KVM 存储中的共享文件夹",
"1d6d3f1668": "无文件",
"93ee55ee88": "上传新文件",
"09deaf92e5": "上传新文件",
"bafa44d7f4": "可用存储",
"d4aa72e4c0": "管理 KVM 存储中的共享文件夹",
"a263c70171": "您确定要下载 ",
"e7f9b3e161": "选择要上传的文件",
"ddeb0eac69": "不支持目录",
"801ab24683": "下载",
"d6e24388b4": "继续上传",
"bf733d8a93": "访问",
"70e23e7d6f": "管理设备的访问控制",
"509820290d": "本地",
"b65477b714": "管理本地访问设备的方式",
"495ec67387": "HTTPS 模式",
"6bb4d0c8d3": "配置安全的 HTTPS 访问",
"0ced0ab47c": "自签名",
"90589c47f0": "自定义",
"303329b9d7": "TLS 证书",
"34395e2d6c": "在下方粘贴您的 TLS 证书。对于证书链,请包含整个链(叶证书、中间证书和根证书)。",
"eb0f48a107": "证书",
"7d25ae26f4": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----",
"d2560860c5": "私钥",
"cac41e667b": "出于安全原因,保存后将不会显示。",
"199bd6a05e": "-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----",
"387cfe77a5": "更新 TLS 设置",
"1083cbc4de": "认证模式",
"d6150fa2c2": "当前模式:",
"7aa025f6e7": "密码保护",
"f8cf7801bd": "无密码",
"119a6cb24a": "禁用保护",
"efd3cc702e": "启用密码",
"8f1e77e0d2": "更改密码",
"3d0de21428": "更新设备访问密码",
"f8508f576c": "远程",
"a8bb6f5f9f": "管理远程访问设备的方式",
"e4abe63a8b": "连接到 TailScale VPN 网络",
"0b86461350": "TailScale 使用 xEdge 服务器",
"a7d199ad4f": "登录网址:",
"3bef87ee46": "等待获取登录网址",
"a3060e541f": "退出中...",
"0baef6200d": "网络 IP",
"2faec1f9f8": "启用",
"761c43fec5": "连接到 ZeroTier VPN 网络",
"3cc3bd7438": "连接中...",
"5028274560": "网络 ID",
"a2c4bef9fa": "连接失败,请重试",
"6327b4e59f": "重试",
"37cfbb7f44": "输入 ZeroTier 网络 ID",
"718f8fd90c": "加入",
"18c514b621": "连接到 EasyTier 服务器",
"a3e117fed1": "网络名称",
"5f1ffd341a": "网络密钥",
"8fc22b3dac": "网络节点",
"11a755d598": "停止",
"ce0be71e33": "日志",
"3119fca100": "节点信息",
"3b92996a28": "输入 EasyTier 网络名称",
"a4c4c07b3d": "输入 EasyTier 网络密钥",
"7a1920d611": "默认",
"63e0339544": "输入 EasyTier 网络节点",
"a6122a65ea": "启动",
"03eb0dbd4f": "连接到 Frp 服务器",
"1bbabcdef3": "编辑 frpc.toml",
"9c088a303a": "输入 frpc 配置",
"867cee98fd": "密码不一致",
"9864ff9420": "请输入旧密码",
"14a714ab22": "请输入新密码",
"65b259c5c8": "更改密码时发生错误",
"b72aabf566": "请输入当前密码",
"2bbdfe1fbb": "禁用密码时发生错误",
"8f5c014173": "密码设置成功",
"733794dd15": "您已成功设置本地设备保护。您的设备现在已安全,防止未经授权的本地访问。",
"ded7f3183f": "密码保护已禁用",
"9e540e52c5": "您已成功禁用本地访问的密码保护。请记住,您的设备现在安全性较低。",
"b14e23413a": "密码更新成功",
"b0695c9383": "您已成功更改本地设备保护密码。请务必记住新密码,以便将来访问。",
"d28f648684": "本地设备保护",
"f8ef48c9cc": "创建一个密码,以保护您的设备免受未经授权的本地访问。",
"ae3bb2a1ac": "新密码",
"d940c35ef0": "输入一个强密码",
"294ec22d2c": "确认新密码",
"795a83a075": "再次输入密码",
"ffa14987a2": "保护设备",
"8dff1db0cc": "现在不设置",
"d8758c7b54": "禁用本地设备保护",
"5b5ff17559": "输入当前密码以禁用本地设备保护。",
"4bc28f132d": "当前密码",
"a454262ec0": "输入当前密码",
"661b5e9f46": "更改本地设备密码",
"e2e1852630": "输入当前密码和新密码以更新本地设备保护。",
"f5ca758fa9": "输入一个新的强密码",
"74f13549ab": "再次输入新密码",
"bb78dd7992": "更新密码",
"9b6545e4ce": "高级",
"9e1b8a17a2": "访问用于故障排除和自定义的附加设置",
"0ce762644e": "仅本地回环模式",
"49aea4318d": "仅限制本地主机访问127.0.0.1",
"af761a417b": "SSH 访问",
"2eec49b1fe": "添加您的 SSH 公钥以启用对设备的安全远程访问",
"96092cbe14": "SSH 公钥",
"3369c97f56": "输入您的 SSH 公钥",
"81bafb2833": "默认 SSH 用户是 ",
"7a941a0f87": "更新 SSH 密钥",
"ccf180df6d": "故障排除模式",
"8a585331c2": "用于故障排除和开发目的的诊断工具和附加控制",
"021fa314ef": "USB 复用",
"5c43d74dbd": "控制 USB 复用状态",
"f6c8ddbadf": "禁用 USB 复用",
"020b92cfbb": "启用 USB 复用",
"f43c0398a4": "重置配置",
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
"0d784092e8": "重置配置",
"a1c58e9422": "外观",
"d414b664a7": "自定义 KVM 界面的外观",
"d721757161": "主题",
"672ca378ea": "选择您喜欢的颜色主题",
"a45da96d0b": "系统",
"9914a0ce04": "浅色",
"a18366b217": "深色",
"0db377921f": "常规",
"3845ee1693": "查看设备的版本信息",
"34b6cd7517": "版本",
"ac863f346e": "应用",
"250be9377e": "应用:加载中...",
"a8463aff5c": "系统:加载中...",
"f77f38f684": "检查更新",
"c5118014fe": "自动更新",
"551e42b147": "自动将设备更新到最新版本",
"3c02a37996": "硬件",
"0eee916f73": "为 KVM 设备配置显示设置和硬件选项",
"ac414bc6f0": "屏幕显示方向",
"b684d709e4": "设置屏幕显示方向",
"68dc91274c": "屏幕显示亮度",
"3a3d04592f": "设置屏幕显示亮度",
"d15305d7a4": "关闭",
"28d0edd045": "低",
"87f8a6ab85": "中",
"655d20c1ca": "高",
"3b9e4fb575": "多久后变暗",
"2496239ef1": "设置等待多久后屏幕背光变暗",
"180bd3b9ac": "多久后关闭显示",
"4e654cc401": "无操作后自动关闭屏幕背光的时间",
"cfe90cea62": "当连接状态改变或被触摸时,显示将唤醒。",
"45c10605e0": "时区",
"3fbe0eeaca": "设置时钟时区",
"5d5b78699e": "设置",
"38046d6028": "LED 绿灯类型",
"8dac3b62fa": "设置绿灯指示的系统状态类型",
"65b20c6a69": "网络连接",
"c4d1012b3e": "网络发送",
"48efe80adc": "网络接收",
"7657bdf983": "内核活动",
"ac8fb475d0": "LED 黄灯类型",
"84906abc3d": "设置黄灯指示的系统状态类型",
"6ce4d85a62": "键盘",
"97a30a5a34": "配置设备的键盘设置",
"47e02c5a49": "目标操作系统的键盘布局",
"e6273eecd6": "粘贴文本会向目标设备发送单独的按键。键盘布局决定发送哪些键码。确保 KVM 中的键盘布局与操作系统设置一致。",
"233f9ed53c": "LED 状态同步",
"a336facf13": "将键盘的 LED 状态与目标设备同步",
"06b9281e39": "自动",
"6d9e6cf689": "仅浏览器",
"6716df9c26": "仅主机",
"27ba83a2e3": "显示按下的键",
"6d5132704c": "在状态栏中显示当前按下的键",
"036f3af7f3": "添加新宏",
"276043dbf0": "创建新的键盘宏",
"7121578730": "编辑宏",
"9605ef9593": "修改键盘宏",
"b578818af4": "删除宏",
"ffbb410a55": "删除中",
"3dd2e50646": "仅延迟",
"7dce122004": "编辑",
"05fd7d5b9c": "您确定要删除",
"784db3951c": "?此操作无法撤销。",
"1294e8c29f": "删除中...",
"2678009cba": "宏",
"d281352b5c": "将按键组合为单个操作以加快工作流程。",
"219122c532": "已达到上限",
"b5401e06fc": "添加新宏",
"616d131a3e": "加载宏中...",
"34a4befad8": "创建您的第一个宏",
"e50cad5a77": "将按键组合为单个操作",
"f2a47c6809": "鼠标",
"7c6596eec1": "配置光标行为和鼠标的交互设置",
"a418c76d1b": "隐藏光标",
"b52b706817": "控制鼠标移动时浏览器隐藏光标",
"0eac3a2cd7": "滚动限制",
"ff136d228b": "减少滚动事件的频率",
"43cac00c74": "抖动器",
"a043cec582": "模拟计算机鼠标移动,防止进入睡眠、待机或屏幕保护模式",
"39e5859fec": "模式",
"03a221978b": "选择鼠标输入模式",
"b51ca26c6c": "绝对",
"7a9ea21ac1": "方便",
"2ca9469819": "相对",
"3df264e35b": "兼容",
"eec89088ee": "网络",
"c2d35aad90": "配置网络设置",
"6c64add3bf": "网络接口的硬件标识符",
"e582019c42": "网络上的设备标识符。留空使用系统默认值",
"026b233624": "mDNS 网络中的设备域名后缀",
"96da6def5d": "自定义域名",
"ef64a3770e": "控制 mDNS多播 DNS运行模式",
"be8226fe0c": "时间同步",
"7e06bd28a6": "配置时间同步设置",
"d4dccb8ca2": "保存设置",
"8750a898cb": "IPv4 模式",
"72c2543791": "配置 IPv4 模式",
"94c252be0e": "DHCP 信息",
"902f16cd13": "无 DHCP 租约信息",
"6a802c3684": "IPv6 模式",
"d29b71c737": "配置 IPv6 模式",
"d323009843": "未配置 IPv6 地址",
"892eec6b1a": "这将请求 DHCP 服务器分配新的 IP 地址。在此过程中,您的设备可能会失去网络连接。",
"697b29c12e": "返回 KVM",
"34e2d1989a": "视频",
"2c50ab9cb6": "配置视频显示和 EDID",
"41d263a988": "视频质量",
"95c3a5fa39": "调整视频流的质量",
"a6c2e30b8e": "视频增强",
"684c75dc2e": "调整颜色设置使视频输出更加鲜艳多彩",
"2e3307b9b1": "饱和度",
"e6efb3393e": "色彩饱和度",
"b25323f997": "亮度",
"720984e4f0": "亮度级别",
"c63ecd19a0": "对比度",
"5a31e20e6d": "对比度级别",
"4418069f82": "恢复默认",
"25793bfcbb": "调整显示器的 EDID 设置",
"34d026e0a9": "自定义 EDID",
"b0258b2bdb": "EDID 详细信息视频模式兼容性。默认设置在大多数情况下有效,但某些独特的 UEFI/BIOS 可能需要调整。",
"85a003df9b": "EDID 文件",
"5c7a666766": "设置自定义 EDID",
"7dad0ba758": "恢复默认",
"7303619cd1": "本地认证方式",
"2c72360ce8": "选择您希望如何本地保护您的 KVM 设备。",
"40735df34e": "您可以随时在设置中更改认证方式。",
"9ffd0df7aa": "设置密码",
"63abf08bec": "创建一个强密码以本地保护您的 KVM 设备。",
"dc647eb65e": "密码",
"a4a22f26c2": "输入密码",
"887f7db126": "确认密码",
"65f1314580": "确认您的密码",
"af21497286": "设置密码",
"c3f88872d6": "此密码将用于保护您的设备数据并防止未经授权的访问。",
"06a7b3bf6e": "所有数据保留在您的本地设备上。"
}

View File

@@ -38,7 +38,9 @@ import {
useRTCStore,
} from "../hooks/stores";
import { UploadDialog } from "@/components/UploadDialog";
import { ConfirmDialog } from "@components/ConfirmDialog";
import { SettingsItem } from "./devices.$id.settings";
import { Checkbox } from "@/components/Checkbox";
import { useReactAt } from 'i18n-auto-extractor/react'
export default function MountRoute() {
const navigate = useNavigate();
@@ -349,16 +351,17 @@ function ModeSelectionView({
selectedMode: "browser" | "url" | "device" | "sd";
setSelectedMode: (mode: "browser" | "url" | "device" | "sd") => void;
}) {
const { $at }= useReactAt();
const { setModalView } = useMountMediaStore();
return (
<div className="w-full space-y-4">
<div className="animate-fadeIn space-y-0 opacity-0">
<h2 className="text-lg leading-tight font-bold dark:text-white">
Virtual Media Source
{$at("Virtual Media Source")}
</h2>
<div className="text-sm leading-snug text-slate-600 dark:text-slate-400">
Choose how you want to mount your virtual media
{$at("Choose how you want to mount your virtual media")}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -382,7 +385,7 @@ function ModeSelectionView({
{
label: "KVM Storage Mount",
value: "device",
description: "Mount previously uploaded files from the KVM storage",
description: "",
icon: LuRadioReceiver,
tag: null,
disabled: false,
@@ -390,7 +393,7 @@ function ModeSelectionView({
{
label: "KVM MicroSD Mount",
value: "sd",
description: "Mount previously uploaded files from the KVM MicroSD",
description: "",
icon: LuRadioReceiver,
tag: null,
disabled: false,
@@ -458,14 +461,14 @@ function ModeSelectionView({
}}
>
<div className="flex gap-x-2 pt-2">
<Button size="MD" theme="blank" onClick={onClose} text="Cancel" />
<Button size="MD" theme="blank" onClick={onClose} text={$at("Cancel")} />
<Button
size="MD"
theme="primary"
onClick={() => {
setModalView(selectedMode);
}}
text="Continue"
text={$at("Continue")}
/>
</div>
</div>
@@ -482,6 +485,7 @@ function BrowserFileView({
onMountFile: (file: File, mode: RemoteVirtualMediaState["mode"]) => void;
mountInProgress: boolean;
}) {
const { $at } = useReactAt();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
@@ -571,11 +575,11 @@ function BrowserFileView({
<UsbModeSelector usbMode={usbMode} setUsbMode={setUsbMode} />
</Fieldset>
<div className="flex space-x-2">
<Button size="MD" theme="blank" text="Back" onClick={onBack} />
<Button size="MD" theme="blank" text={$at("Back")} onClick={onBack} />
<Button
size="MD"
theme="primary"
text="Mount File"
text={$at("Mount")}
onClick={handleMount}
disabled={!selectedFile || mountInProgress}
loading={mountInProgress}
@@ -758,6 +762,7 @@ function DeviceFileView({
}[]
>([]);
const { $at } = useReactAt();
const [selected, setSelected] = useState<string | null>(null);
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
const [currentPage, setCurrentPage] = useState(1);
@@ -868,12 +873,55 @@ function DeviceFileView({
setCurrentPage(prev => Math.min(prev + 1, totalPages));
};
const [autoMountSystemInfo, setAutoMountSystemInfo] = useState(false);
const handleAutoMountSystemInfoChange = (value: boolean) => {
send("setAutoMountSystemInfo", { enabled: value }, response => {
if ("error" in response) {
notifications.error(`Failed to set auto mount system_info.img: ${response.error.message}`);
return;
}
setAutoMountSystemInfo(value);
});
}
useEffect(() => {
send("getAutoMountSystemInfo", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to load auto mount system_info.img: ${resp.error.data || "Unknown error"}`,
);
setAutoMountSystemInfo(false);
} else {
setAutoMountSystemInfo(resp.result as boolean);
}
});
}, [send, setAutoMountSystemInfo])
return (
<div className="w-full space-y-4">
<ViewHeader
title="Mount from KVM Storage"
description="Select an image to mount from the KVM storage"
title={$at("Mount from KVM Storage")}
description={$at("Select the image you want to mount from the KVM storage")}
/>
<div
className="w-full animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<SettingsItem
title={$at("Automatically mount system_info.img")}
description={$at("Mount system_info.img automatically when the KVM startup")}
>
<Checkbox
checked={autoMountSystemInfo}
onChange={(e) => handleAutoMountSystemInfoChange(e.target.checked)}
/>
</SettingsItem>
</div>
<hr className="border-slate-800/20 dark:border-slate-300/20" />
<div
className="w-full animate-fadeIn opacity-0"
style={{
@@ -888,17 +936,17 @@ function DeviceFileView({
<div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" />
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
No images available
{$at("No images available")}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Upload an image to start virtual media mounting.
{$at("Upload an image to start virtual media mounting.")}
</p>
</div>
<div>
<Button
size="SM"
theme="primary"
text="Upload a new image"
text={$at("Upload a new image")}
onClick={() => onNewImageClick()}
/>
</div>
@@ -919,7 +967,7 @@ function DeviceFileView({
if (!selectedFile) return;
if (
window.confirm(
"Are you sure you want to delete " + selectedFile.name + "?",
$at("Are you sure you want to delete " + selectedFile.name + "?"),
)
) {
handleDeleteFile(selectedFile);
@@ -943,14 +991,14 @@ function DeviceFileView({
<Button
size="XS"
theme="light"
text="Previous"
text={$at("Previous")}
onClick={handlePreviousPage}
disabled={currentPage === 1}
/>
<Button
size="XS"
theme="light"
text="Next"
text={$at("Next")}
onClick={handleNextPage}
disabled={currentPage === totalPages}
/>
@@ -974,12 +1022,12 @@ function DeviceFileView({
<UsbModeSelector usbMode={usbMode} setUsbMode={setUsbMode} />
</Fieldset>
<div className="flex items-center gap-x-2">
<Button size="MD" theme="blank" text="Back" onClick={() => onBack()} />
<Button size="MD" theme="blank" text={$at("Back")} onClick={() => onBack()} />
<Button
size="MD"
disabled={selected === null || mountInProgress}
theme="primary"
text="Mount File"
text={$at("Mount")}
loading={mountInProgress}
onClick={() =>
onMountStorageFile(
@@ -999,7 +1047,7 @@ function DeviceFileView({
}}
>
<div className="flex items-center gap-x-2">
<Button size="MD" theme="light" text="Back" onClick={() => onBack()} />
<Button size="MD" theme="light" text={$at("Back")} onClick={() => onBack()} />
</div>
</div>
)}
@@ -1013,10 +1061,10 @@ function DeviceFileView({
>
<div className="flex justify-between text-sm">
<span className="font-medium text-black dark:text-white">
Available Storage
{$at("Available space")}
</span>
<span className="text-slate-700 dark:text-slate-300">
{percentageUsed}% used
{percentageUsed}% {$at("used")}
</span>
</div>
<div className="h-3.5 w-full overflow-hidden rounded-xs bg-slate-200 dark:bg-slate-700">
@@ -1027,10 +1075,10 @@ function DeviceFileView({
</div>
<div className="flex justify-between text-sm text-slate-600">
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesUsed)} used
{formatters.bytes(bytesUsed)} {$at("used")}
</span>
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesFree)} free
{formatters.bytes(bytesFree)} {$at("free")}
</span>
</div>
</div>
@@ -1047,7 +1095,7 @@ function DeviceFileView({
size="MD"
theme="light"
fullWidth
text="Upload a new image"
text={$at("Upload a new image")}
onClick={() => onNewImageClick()}
/>
</div>
@@ -1074,7 +1122,7 @@ function SDFileView({
createdAt: string;
}[]
>([]);
const { $at }= useReactAt();
const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
@@ -1242,16 +1290,16 @@ function SDFileView({
return (
<div className="w-full space-y-4">
<ViewHeader
title="Mount from KVM MicroSD Card"
description="Select an image to mount from the KVM storage"
title={$at("Mount from KVM MicroSD Card")}
description={$at("Select an image to mount from the KVM storage")}
/>
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
<ExclamationTriangleIcon className="mx-auto h-6 w-6 text-red-500" />
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
{sdMountStatus === "none"
? "No SD card detected"
: "SD card mount failed"}
? $at("No SD card detected")
: $at("SD card mount failed")}
<Button
size="XS"
disabled={loading}
@@ -1262,8 +1310,8 @@ function SDFileView({
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
{sdMountStatus === "none"
? "Please insert an SD card and try again."
: "Please format the SD card and try again."}
? $at("Please insert an SD card and try again.")
: $at("Please format the SD card and try again.")}
</p>
</div>
</div>
@@ -1274,8 +1322,8 @@ function SDFileView({
return (
<div className="w-full space-y-4">
<ViewHeader
title="Mount from KVM MicroSD Card"
description="Select an image to mount from the KVM storage"
title={$at("Mount from KVM MicroSD Card")}
description={$at("Select an image to mount from the KVM storage")}
/>
<div
className="w-full animate-fadeIn opacity-0"
@@ -1291,10 +1339,10 @@ function SDFileView({
<div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" />
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No images available
{$at("No images available")}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Upload an image to start virtual media mounting.
{$at("Upload an image to start virtual media mounting.")}
</p>
</div>
<div>
@@ -1302,7 +1350,7 @@ function SDFileView({
size="SM"
disabled={loading}
theme="primary"
text="Upload a new image"
text={$at("Upload a new image")}
onClick={() => onNewImageClick()}
/>
</div>
@@ -1321,7 +1369,7 @@ function SDFileView({
onDelete={() => {
const selectedFile = onStorageFiles.find(f => f.name === file.name);
if (!selectedFile) return;
if (window.confirm("Are you sure you want to delete " + selectedFile.name + "?")) {
if (window.confirm($at("Are you sure you want to delete " + selectedFile.name + "?") )) {
handleSDDeleteFile(selectedFile);
}
}}
@@ -1343,14 +1391,14 @@ function SDFileView({
<Button
size="XS"
theme="light"
text="Previous"
text={$at("Previous")}
onClick={handlePreviousPage}
disabled={currentPage === 1 || loading}
/>
<Button
size="XS"
theme="light"
text="Next"
text={$at("Next")}
onClick={handleNextPage}
disabled={currentPage === totalPages || loading}
/>
@@ -1374,12 +1422,12 @@ function SDFileView({
<UsbModeSelector usbMode={usbMode} setUsbMode={setUsbMode} />
</Fieldset>
<div className="flex items-center gap-x-2">
<Button size="MD" theme="blank" text="Back" onClick={() => onBack()} />
<Button size="MD" theme="blank" text={$at("Back")} onClick={() => onBack()} />
<Button
size="MD"
disabled={selected === null || mountInProgress || loading}
theme="primary"
text="Mount File"
text={$at("Mount File")}
loading={mountInProgress}
onClick={() =>
onMountStorageFile(
@@ -1399,7 +1447,7 @@ function SDFileView({
}}
>
<div className="flex items-center gap-x-2">
<Button size="MD" theme="light" text="Back" onClick={() => onBack()} />
<Button size="MD" theme="light" text={$at("Back")} onClick={() => onBack()} />
</div>
</div>
)}
@@ -1413,10 +1461,10 @@ function SDFileView({
>
<div className="flex justify-between text-sm">
<span className="font-medium text-black dark:text-white">
Available Storage
{$at("Available Space")}
</span>
<span className="text-slate-700 dark:text-slate-300">
{percentageUsed}% used
{percentageUsed}% {$at("used")}
</span>
</div>
<div className="h-3.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700">
@@ -1427,10 +1475,10 @@ function SDFileView({
</div>
<div className="flex justify-between text-sm text-slate-600">
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesUsed)} used
{formatters.bytes(bytesUsed)} {$at("used")}
</span>
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesFree)} free
{formatters.bytes(bytesFree)} {$at("free")}
</span>
</div>
</div>
@@ -1448,7 +1496,7 @@ function SDFileView({
disabled={loading}
theme="light"
fullWidth
text="Upload a new image"
text={$at("Upload a New Image")}
onClick={() => onNewImageClick()}
/>
</div>
@@ -1466,7 +1514,7 @@ function SDFileView({
disabled={loading}
theme="light"
fullWidth
text="Unmount SD Card"
text={$at("Unmount Micro SD Card")}
onClick={() => handleUnmountSDStorage()}
className="text-red-500 dark:text-red-400"
/>
@@ -1489,6 +1537,7 @@ function UploadFileView({
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">(
"idle",
);
const { $at }= useReactAt();
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const [uploadedFileSize, setUploadedFileSize] = useState<number | null>(null);
@@ -1772,11 +1821,11 @@ function UploadFileView({
<div className="w-full space-y-4">
<UploadDialog
open={true}
title="Upload New Image"
title={$at("Upload a New Image")}
description={
incompleteFileName
? `Continue uploading "${incompleteFileName}"`
: "Select an image file to upload to KVM storage"
? $at(`Continue uploading "${incompleteFileName}"`)
: $at("Select an image file to upload to KVM storage")
}
>
<div
@@ -1813,11 +1862,11 @@ function UploadFileView({
</div>
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
{incompleteFileName
? `Click to select "${incompleteFileName.replace(".incomplete", "")}"`
: "Click to select a file"}
? `"${$at("Click to select")}" "${incompleteFileName.replace(".incomplete", "")}"`
: $at("Click to select a file")}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Supported formats: ISO, IMG
{$at("Supported formats: ISO, IMG")}
</p>
</div>
)}
@@ -1832,7 +1881,7 @@ function UploadFileView({
</Card>
</div>
<h3 className="leading-non text-lg font-semibold text-black dark:text-white">
Uploading {formatters.truncateMiddle(uploadedFileName, 30)}
{$at("Uploading")} {formatters.truncateMiddle(uploadedFileName, 30)}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
{formatters.bytes(uploadedFileSize || 0)}
@@ -1845,11 +1894,11 @@ function UploadFileView({
></div>
</div>
<div className="flex justify-between text-xs text-slate-600 dark:text-slate-400">
<span>Uploading...</span>
<span>{$at("Uploading...")}</span>
<span>
{uploadSpeed !== null
? `${formatters.bytes(uploadSpeed)}/s`
: "Calculating..."}
: $at("Calculating...")}
</span>
</div>
</div>
@@ -1866,11 +1915,10 @@ function UploadFileView({
</Card>
</div>
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
Upload successful
{$at("Upload Successful")}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
{formatters.truncateMiddle(uploadedFileName, 40)} has been
uploaded
{formatters.truncateMiddle(uploadedFileName, 40)} {$at("Uploaded")}
</p>
</div>
)}
@@ -1913,7 +1961,7 @@ function UploadFileView({
<Button
size="MD"
theme="light"
text="Cancel Upload"
text={$at("Cancel Upload")}
onClick={() => {
onCancelUpload();
setUploadState("idle");
@@ -1927,7 +1975,7 @@ function UploadFileView({
<Button
size="MD"
theme={uploadState === "success" ? "primary" : "light"}
text="Back to Overview"
text={$at("Back to Overview")}
onClick={onBack}
/>
)}
@@ -1990,6 +2038,7 @@ function PreUploadedImageItem({
onDelete: () => void;
onContinueUpload: () => void;
}) {
const { $at }= useReactAt();
const [isHovering, setIsHovering] = useState(false);
return (
<label
@@ -2034,7 +2083,7 @@ function PreUploadedImageItem({
size="XS"
theme="light"
LeadingIcon={TrashIcon}
text="Delete"
text={$at("Delete")}
onClick={e => {
e.stopPropagation();
onDelete();
@@ -2055,7 +2104,7 @@ function PreUploadedImageItem({
<Button
size="XS"
theme="light"
text="Continue uploading"
text={$at("Continue uploading")}
onClick={e => {
e.stopPropagation();
onContinueUpload();
@@ -2087,9 +2136,12 @@ function UsbModeSelector({
usbMode: RemoteVirtualMediaState["mode"];
setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void;
}) {
const { $at } = useReactAt();
return (
<div className="flex flex-col items-start space-y-1 select-none">
<label className="text-sm font-semibold text-black dark:text-white">Mount as</label>
<label className="text-sm font-semibold text-black dark:text-white">
{ $at("Mount as") }
</label>
<div className="flex space-x-4">
<label htmlFor="cdrom" className="flex items-center">
<input

View File

@@ -31,7 +31,7 @@ import {
import { UploadDialog } from "@/components/UploadDialog";
import { sync } from "framer-motion";
import Fieldset from "@/components/Fieldset";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function MtpRoute() {
const navigate = useNavigate();
@@ -166,32 +166,33 @@ function MtpModeSelectionView({
selectedMode: "mtp_device" | "mtp_sd";
setSelectedMode: (mode: "mtp_device" | "mtp_sd") => void;
}) {
const { $at } = useReactAt();
const { setModalView } = useMountMediaStore();
return (
<div className="w-full space-y-4">
<div className="animate-fadeIn space-y-0 opacity-0">
<h2 className="text-lg leading-tight font-bold dark:text-white">
Virtual Media Source
{$at("Shared Folders")}
</h2>
<div className="text-sm leading-snug text-slate-600 dark:text-slate-400">
Choose how you want to mount your virtual media
{$at("Select the shared folder that you want to manage")}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{[
{
label: "KVM Storage Manager",
label: "KVM Storage",
value: "mtp_device",
description: "Manage the shared folder located on eMMC",
description: "",
icon: LuRadioReceiver,
tag: null,
disabled: false,
},
{
label: "KVM MicroSD Manager",
label: "KVM MicroSD",
value: "mtp_sd",
description: "Manage the shared folder located on MicroSD",
description: "",
icon: LuRadioReceiver,
tag: null,
disabled: false,
@@ -259,14 +260,14 @@ function MtpModeSelectionView({
}}
>
<div className="flex gap-x-2 pt-2">
<Button size="MD" theme="blank" onClick={onClose} text="Cancel" />
<Button size="MD" theme="blank" onClick={onClose} text={$at("Cancel")} />
<Button
size="MD"
theme="primary"
onClick={() => {
setModalView(selectedMode);
}}
text="Continue"
text={$at("Continue")}
/>
</div>
</div>
@@ -288,7 +289,7 @@ function DeviceFileView({
createdAt: string;
}[]
>([]);
const { $at }= useReactAt();
const [selected, setSelected] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const filesPerPage = 5;
@@ -402,8 +403,8 @@ function DeviceFileView({
return (
<div className="w-full space-y-4">
<ViewHeader
title="Mount from KVM Storage"
description="Select an image to mount from the KVM storage"
title={$at("Manage Shared Folders in KVM Storage")}
description=""
/>
<div
className="w-full animate-fadeIn opacity-0"
@@ -419,17 +420,17 @@ function DeviceFileView({
<div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" />
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
No images available
{$at("No files")}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Upload an image to start virtual media mounting.
{$at("Upload a new file")}
</p>
</div>
<div>
<Button
size="SM"
theme="primary"
text="Upload a new File"
text={$at("Upload a New File")}
onClick={() => onNewImageClick()}
/>
</div>
@@ -512,7 +513,7 @@ function DeviceFileView({
}}
>
<div className="flex items-center gap-x-2">
<Button size="MD" theme="blank" text="Back" onClick={() => onBack()} />
<Button size="MD" theme="blank" text={$at("Back")} onClick={() => onBack()} />
</div>
</div>
) : (
@@ -524,7 +525,7 @@ function DeviceFileView({
}}
>
<div className="flex items-center gap-x-2">
<Button size="MD" theme="light" text="Back" onClick={() => onBack()} />
<Button size="MD" theme="light" text={$at("Back")} onClick={() => onBack()} />
</div>
</div>
)}
@@ -538,10 +539,10 @@ function DeviceFileView({
>
<div className="flex justify-between text-sm">
<span className="font-medium text-black dark:text-white">
Available Storage
{$at("Available Storage")}
</span>
<span className="text-slate-700 dark:text-slate-300">
{percentageUsed}% used
{percentageUsed}% {$at("used")}
</span>
</div>
<div className="h-3.5 w-full overflow-hidden rounded-xs bg-slate-200 dark:bg-slate-700">
@@ -552,10 +553,10 @@ function DeviceFileView({
</div>
<div className="flex justify-between text-sm text-slate-600">
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesUsed)} used
{formatters.bytes(bytesUsed)} {$at("used")}
</span>
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesFree)} free
{formatters.bytes(bytesFree)} {$at("free")}
</span>
</div>
</div>
@@ -572,7 +573,7 @@ function DeviceFileView({
size="MD"
theme="light"
fullWidth
text="Upload a new File"
text={$at("Upload a New File")}
onClick={() => onNewImageClick()}
/>
</div>
@@ -715,6 +716,7 @@ function SDFileView({
a.remove();
}
const { $at }= useReactAt();
const indexOfLastFile = currentPage * filesPerPage;
const indexOfFirstFile = indexOfLastFile - filesPerPage;
const currentFiles = onStorageFiles.slice(indexOfFirstFile, indexOfLastFile);
@@ -777,8 +779,8 @@ function SDFileView({
return (
<div className="w-full space-y-4">
<ViewHeader
title="Mount from KVM MicroSD Card"
description="Select an image to mount from the KVM storage"
title={$at("Manage Shared Folder in KVM Storage")}
description=""
/>
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
@@ -809,8 +811,8 @@ function SDFileView({
return (
<div className="w-full space-y-4">
<ViewHeader
title="Mount from KVM MicroSD Card"
description="Select an image to mount from the KVM storage"
title={$at("Manage Shared Folder in KVM Storage")}
description=""
/>
<div
className="w-full animate-fadeIn opacity-0"
@@ -826,10 +828,10 @@ function SDFileView({
<div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" />
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No images available
{$at("No files")}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Upload a file.
{$at("Upload a new file")}
</p>
</div>
<div>
@@ -837,7 +839,7 @@ function SDFileView({
size="SM"
disabled={loading}
theme="primary"
text="Upload a new File"
text={$at("Upload a New File")}
onClick={() => onNewImageClick()}
/>
</div>
@@ -858,7 +860,7 @@ function SDFileView({
if (!selectedFile) return;
if (
window.confirm(
"Are you sure you want to download " + selectedFile.name + "?",
$at("Are you sure you want to download " + selectedFile.name + "?"),
)
) {
handleSDDownloadFile(selectedFile);
@@ -867,7 +869,7 @@ function SDFileView({
onDelete={() => {
const selectedFile = onStorageFiles.find(f => f.name === file.name);
if (!selectedFile) return;
if (window.confirm("Are you sure you want to delete " + selectedFile.name + "?")) {
if (window.confirm($at("Are you sure you want to delete " + selectedFile.name + "?"))) {
handleSDDeleteFile(selectedFile);
}
}}
@@ -916,7 +918,7 @@ function SDFileView({
}}
>
<div className="flex items-center gap-x-2">
<Button size="MD" theme="blank" text="Back" onClick={() => onBack()} />
<Button size="MD" theme="blank" text={$at("Back")} onClick={() => onBack()} />
<Button
size="MD"
disabled={loading}
@@ -935,7 +937,7 @@ function SDFileView({
}}
>
<div className="flex items-center gap-x-2 ml-auto ml-auto">
<Button size="MD" theme="light" text="Back" onClick={() => onBack()} />
<Button size="MD" theme="light" text={$at("Back")} onClick={() => onBack()} />
<Button
size="MD"
theme="light"
@@ -955,10 +957,10 @@ function SDFileView({
>
<div className="flex justify-between text-sm">
<span className="font-medium text-black dark:text-white">
Available Storage
{$at("Available Storage")}
</span>
<span className="text-slate-700 dark:text-slate-300">
{percentageUsed}% used
{percentageUsed}% {$at("used")}
</span>
</div>
<div className="h-3.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700">
@@ -969,10 +971,10 @@ function SDFileView({
</div>
<div className="flex justify-between text-sm text-slate-600">
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesUsed)} used
{formatters.bytes(bytesUsed)} {$at("used")}
</span>
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesFree)} free
{formatters.bytes(bytesFree)} {$at("free")}
</span>
</div>
</div>
@@ -990,7 +992,7 @@ function SDFileView({
disabled={loading}
theme="light"
fullWidth
text="Upload a new File"
text={$at("Upload a New File")}
onClick={() => onNewImageClick()}
/>
</div>
@@ -1008,7 +1010,7 @@ function SDFileView({
disabled={loading}
theme="light"
fullWidth
text="Unmount SD Card"
text={$at("Unmount Micro SD Card")}
onClick={() => handleUnmountSDStorage()}
className="text-red-500 dark:text-red-400"
/>
@@ -1029,6 +1031,7 @@ function UploadFileView({
incompleteFileName?: string;
media?: string;
}) {
const { $at }= useReactAt();
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">(
"idle",
);
@@ -1315,11 +1318,11 @@ function UploadFileView({
<div className="w-full space-y-4">
<UploadDialog
open={true}
title="Upload New Image"
title={$at("Upload a New File")}
description={
incompleteFileName
? `Continue uploading "${incompleteFileName}"`
: "Select an image file to upload to KVM storage"
? $at(`Continue uploading "${incompleteFileName}"`)
: $at("Select a file to upload")
}
>
<div
@@ -1356,11 +1359,11 @@ function UploadFileView({
</div>
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
{incompleteFileName
? `Click to select "${incompleteFileName.replace(".incomplete", "")}"`
: "Click to select a file"}
? $at(`Click to select "${incompleteFileName.replace(".incomplete", "")}"`)
: $at("Click to select a file")}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Do not support directory
{$at("Do not support directories")}
</p>
</div>
)}
@@ -1375,7 +1378,7 @@ function UploadFileView({
</Card>
</div>
<h3 className="leading-non text-lg font-semibold text-black dark:text-white">
Uploading {formatters.truncateMiddle(uploadedFileName, 30)}
{$at("Uploading")} {formatters.truncateMiddle(uploadedFileName, 30)}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
{formatters.bytes(uploadedFileSize || 0)}
@@ -1388,11 +1391,11 @@ function UploadFileView({
></div>
</div>
<div className="flex justify-between text-xs text-slate-600 dark:text-slate-400">
<span>Uploading...</span>
<span>{$at("Uploading...")}...</span>
<span>
{uploadSpeed !== null
? `${formatters.bytes(uploadSpeed)}/s`
: "Calculating..."}
: $at("Calculating...")}
</span>
</div>
</div>
@@ -1409,11 +1412,10 @@ function UploadFileView({
</Card>
</div>
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
Upload successful
{$at("Upload Successful")}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
{formatters.truncateMiddle(uploadedFileName, 40)} has been
uploaded
{formatters.truncateMiddle(uploadedFileName, 40)} {$at("Uploaded")}
</p>
</div>
)}
@@ -1455,7 +1457,7 @@ function UploadFileView({
<Button
size="MD"
theme="light"
text="Cancel Upload"
text={$at("Cancel Upload")}
onClick={() => {
onCancelUpload();
setUploadState("idle");
@@ -1532,6 +1534,7 @@ function PreUploadedImageItem({
onDelete: () => void;
onContinueUpload: () => void;
}) {
const { $at }= useReactAt();
const [isHovering, setIsHovering] = useState(false);
return (
<label
@@ -1571,7 +1574,7 @@ function PreUploadedImageItem({
size="XS"
theme="light"
LeadingIcon={LuDownload}
text="Download"
text={$at("Download")}
onClick={e => {
e.stopPropagation();
onDownload();
@@ -1588,7 +1591,7 @@ function PreUploadedImageItem({
size="XS"
theme="light"
LeadingIcon={TrashIcon}
text="Delete"
text={$at("Delete")}
onClick={e => {
e.stopPropagation();
onDelete();
@@ -1600,7 +1603,7 @@ function PreUploadedImageItem({
<Button
size="XS"
theme="light"
text="Continue uploading"
text={$at("Continue Uploading")}
onClick={e => {
e.stopPropagation();
onContinueUpload();

View File

@@ -23,6 +23,11 @@ import { useVpnStore } from "@/hooks/stores";
import Checkbox from "../components/Checkbox";
import { LogDialog } from "../components/LogDialog";
import {useReactAt} from 'i18n-auto-extractor/react'
import AutoHeight from "@/components/AutoHeight";
export interface TailScaleResponse {
state: string;
@@ -41,6 +46,16 @@ export interface FrpcResponse {
running: boolean;
}
export interface EasyTierRunningResponse {
running: boolean;
}
export interface EasyTierResponse {
name: string;
secret: string;
node: string;
}
export interface TLSState {
mode: "self-signed" | "custom" | "disabled";
@@ -59,6 +74,7 @@ const loader = async () => {
};
export default function SettingsAccessIndexRoute() {
const { $at }= useReactAt();
const loaderData = useLoaderData() as LocalDevice | null;
const { navigateTo } = useDeviceUiNavigation();
@@ -94,7 +110,23 @@ export default function SettingsAccessIndexRoute() {
const [frpcToml, setFrpcToml] = useState<string>("");
const [frpcLog, setFrpcLog] = useState<string>("");
const [showFrpcLogModal, setShowFrpcLogModal] = useState(false);
const [frpcStatus, setFrpcRunningStatus] = useState<FrpcResponse>({ running: false });
const [frpcRunningStatus, setFrpcRunningStatus] = useState<FrpcResponse>({ running: false });
const [tempEasyTierNetworkName, setTempEasyTierNetworkName] = useState("");
const [tempEasyTierNetworkSecret, setTempEasyTierNetworkSecret] = useState("");
const [tempEasyTierNetworkNodeMode, setTempEasyTierNetworkNodeMode] = useState("default");
const [tempEasyTierNetworkNode, setTempEasyTierNetworkNode] = useState("tcp://public.easytier.cn:11010");
const [easyTierRunningStatus, setEasyTierRunningStatus] = useState<EasyTierRunningResponse>({ running: false });
const [showEasyTierLogModal, setShowEasyTierLogModal] = useState(false);
const [showEasyTierNodeInfoModal, setShowEasyTierNodeInfoModal] = useState(false);
const [easyTierLog, setEasyTierLog] = useState<string>("");
const [easyTierNodeInfo, setEasyTierNodeInfo] = useState<string>("");
const [easyTierConfig, setEasyTierConfig] = useState<EasyTierResponse>({
name: "",
secret: "",
node: "",
});
const getTLSState = useCallback(() => {
send("getTLSState", {}, resp => {
@@ -207,12 +239,12 @@ export default function SettingsAccessIndexRoute() {
});
},[send]);
const handleTailScaleCanel = useCallback(() => {
const handleTailScaleCancel = useCallback(() => {
setIsDisconnecting(true);
send("canelTailScale", {}, resp => {
send("cancelTailScale", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to logout TailScale: ${resp.error.data || "Unknown error"}`,
`Failed to cancel TailScale: ${resp.error.data || "Unknown error"}`,
);
setIsDisconnecting(false);
return;
@@ -289,7 +321,7 @@ export default function SettingsAccessIndexRoute() {
}, [send, frpcToml]);
const handleStopFrpc = useCallback(() => {
send("stopFrpc", { frpcToml }, resp => {
send("stopFrpc", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to stop frpc: ${resp.error.data || "Unknown error"}`,
@@ -345,25 +377,133 @@ export default function SettingsAccessIndexRoute() {
getFrpcToml();
}, [getFrpcStatus, getFrpcToml]);
const handleStartEasyTier = useCallback(() => {
if (!tempEasyTierNetworkName || !tempEasyTierNetworkSecret || !tempEasyTierNetworkNode) {
notifications.error("Please enter EasyTier network name, secret and node");
return;
}
setEasyTierConfig({
name: tempEasyTierNetworkName,
secret: tempEasyTierNetworkSecret,
node: tempEasyTierNetworkNode,
});
send("startEasyTier", { name: tempEasyTierNetworkName, secret: tempEasyTierNetworkSecret, node: tempEasyTierNetworkNode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to start EasyTier: ${resp.error.data || "Unknown error"}`,
);
setEasyTierRunningStatus({ running: false });
return;
}
notifications.success("EasyTier started");
setEasyTierRunningStatus({ running: true });
});
}, [send, tempEasyTierNetworkName, tempEasyTierNetworkSecret, tempEasyTierNetworkNode]);
const handleStopEasyTier = useCallback(() => {
send("stopEasyTier", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to stop EasyTier: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("EasyTier stopped");
setEasyTierRunningStatus({ running: false });
});
}, [send]);
const handleGetEasyTierLog = useCallback(() => {
send("getEasyTierLog", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get EasyTier log: ${resp.error.data || "Unknown error"}`,
);
setEasyTierLog("");
return;
}
setEasyTierLog(resp.result as string);
setShowEasyTierLogModal(true);
});
}, [send]);
const handleGetEasyTierNodeInfo = useCallback(() => {
send("getEasyTierNodeInfo", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get EasyTier Node Info: ${resp.error.data || "Unknown error"}`,
);
setEasyTierNodeInfo("");
return;
}
setEasyTierNodeInfo(resp.result as string);
setShowEasyTierNodeInfoModal(true);
});
}, [send]);
const getEasyTierConfig = useCallback(() => {
send("getEasyTierConfig", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get EasyTier config: ${resp.error.data || "Unknown error"}`,
);
return;
}
const result = resp.result as EasyTierResponse;
setEasyTierConfig({
name: result.name,
secret: result.secret,
node: result.node,
});
});
}, [send]);
const getEasyTierStatus = useCallback(() => {
console.log("getEasyTierStatus")
send("getEasyTierStatus", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get EasyTier status: ${resp.error.data || "Unknown error"}`,
);
return;
}
setEasyTierRunningStatus(resp.result as EasyTierRunningResponse);
});
}, [send]);
useEffect(() => {
getEasyTierConfig();
getEasyTierStatus();
}, [getEasyTierStatus, getEasyTierConfig]);
useEffect(() => {
if (tempEasyTierNetworkNodeMode === 'default') {
setTempEasyTierNetworkNode('tcp://public.easytier.cn:11010');
} else {
setTempEasyTierNetworkNode('');
}
}, [tempEasyTierNetworkNodeMode]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Access"
description="Manage the Access Control of the device"
title={$at("Access")}
description={$at("Manage the Access Control of the device")}
/>
{loaderData?.authMode && (
<>
<div className="space-y-4">
<SettingsSectionHeader
title="Local"
description="Manage the mode of local access to the device"
title={$at("Local")}
description={$at("Manage the mode of local access to the device")}
/>
<>
<SettingsItem
title="HTTPS Mode"
title={$at("HTTPS Mode")}
badge="Experimental"
description="Configure secure HTTPS access to your device"
description={$at("Configure secure HTTPS access to your device")}
>
<SelectMenuBasic
size="SM"
@@ -371,9 +511,9 @@ export default function SettingsAccessIndexRoute() {
onChange={e => handleTlsModeChange(e.target.value)}
disabled={tlsMode === "unknown"}
options={[
{ value: "disabled", label: "Disabled" },
{ value: "self-signed", label: "Self-signed" },
{ value: "custom", label: "Custom" },
{ value: "disabled", label: $at("Disabled") },
{ value: "self-signed", label: $at("Self-signed") },
{ value: "custom", label: $at("Custom") },
]}
/>
</SettingsItem>
@@ -382,15 +522,15 @@ export default function SettingsAccessIndexRoute() {
<div className="mt-4 space-y-4">
<div className="space-y-4">
<SettingsItem
title="TLS Certificate"
description="Paste your TLS certificate below. For certificate chains, include the entire chain (leaf, intermediate, and root certificates)."
title={$at("TLS Certificate")}
description={$at("Paste your TLS certificate below. For certificate chains, include the entire chain (leaf, intermediate, and root certificates).")}
/>
<div className="space-y-4">
<TextAreaWithLabel
label="Certificate"
label={$at("Certificate")}
rows={3}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
$at("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----")
}
value={tlsCert}
onChange={e => handleTlsCertChange(e.target.value)}
@@ -400,11 +540,11 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-4">
<div className="space-y-4">
<TextAreaWithLabel
label="Private Key"
description="For security reasons, it will not be displayed after saving."
label={$at("Private Key")}
description={$at("For security reasons, it will not be displayed after saving.")}
rows={3}
placeholder={
"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
$at("-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----")
}
value={tlsKey}
onChange={e => handleTlsKeyChange(e.target.value)}
@@ -416,7 +556,7 @@ export default function SettingsAccessIndexRoute() {
<Button
size="SM"
theme="primary"
text="Update TLS Settings"
text={$at("Update TLS Settings")}
onClick={handleCustomTlsUpdate}
/>
</div>
@@ -424,14 +564,14 @@ export default function SettingsAccessIndexRoute() {
)}
<SettingsItem
title="Authentication Mode"
description={`Current mode: ${loaderData.authMode === "password" ? "Password protected" : "No password"}`}
title={$at("Authentication Mode")}
description={`${$at("Current mode:")} ${loaderData.authMode === "password" ? $at("Password protected") : $at("No password")}`}
>
{loaderData.authMode === "password" ? (
<Button
size="SM"
theme="light"
text="Disable Protection"
text={$at("Disable Protection")}
onClick={() => {
navigateTo("./local-auth", { state: { init: "deletePassword" } });
}}
@@ -440,7 +580,7 @@ export default function SettingsAccessIndexRoute() {
<Button
size="SM"
theme="light"
text="Enable Password"
text={$at("Enable Password")}
onClick={() => {
navigateTo("./local-auth", { state: { init: "createPassword" } });
}}
@@ -451,13 +591,13 @@ export default function SettingsAccessIndexRoute() {
{loaderData.authMode === "password" && (
<SettingsItem
title="Change Password"
description="Update your device access password"
title={$at("Change Password")}
description={$at("Update your device access password")}
>
<Button
size="SM"
theme="light"
text="Change Password"
text={$at("Change Password")}
onClick={() => {
navigateTo("./local-auth", { state: { init: "updatePassword" } });
}}
@@ -471,224 +611,407 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-4">
<SettingsSectionHeader
title="Remote"
description="Manage the mode of Remote access to the device"
title={$at("Remote")}
description={$at("Manage the mode of Remote access to the device")}
/>
<div className="space-y-4">
{/* Add TailScale settings item */}
<SettingsItem
title="TailScale"
badge="Experimental"
description="Connect to TailScale VPN network"
>
</SettingsItem>
<SettingsItem
title=""
description="TailScale use xEdge server"
>
<Checkbox
checked={tailScaleXEdge}
onChange={e => {
if (tailScaleConnectionState !== "disconnected") {
notifications.error("TailScale is running and this setting cannot be modified");
return;
}
handleTailScaleXEdgeChange(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title=""
description=""
>
<div className="space-y-4">
{ ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
<Button
size="SM"
theme="light"
text="Enable"
onClick={handleTailScaleLogin}
/>
)}
</div>
</SettingsItem>
</div>
<div className="space-y-4">
{/* Add TailScale settings item */}
<SettingsItem
title="TailScale"
badge="Experimental"
description={$at("Connect to TailScale VPN network")}
>
</SettingsItem>
</div>
<div className="space-y-4">
{tailScaleConnectionState === "connecting" && (
<div className="flex items-center justify-between gap-x-2">
<p>Connecting...</p>
<Button
size="SM"
theme="light"
text="Canel"
onClick={handleTailScaleCanel}
/>
</div>
)}
{tailScaleConnectionState === "connected" && (
<div className="space-y-4">
<div className="flex items-center gap-x-2 justify-between">
{tailScaleLoginUrl && (
<p>Login URL: <a href={tailScaleLoginUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400">LoginUrl</a></p>
)}
{!tailScaleLoginUrl && (
<p>Wait to obtain the Login URL</p>
)}
<Button
size="SM"
theme="light"
text= { isDisconnecting ? "Quitting..." : "Quit"}
onClick={handleTailScaleLogout}
disabled={ isDisconnecting === true }
/>
</div>
</div>
)}
{tailScaleConnectionState === "logined" && (
<div className="space-y-4">
<div className="flex items-center gap-x-2 justify-between">
<p>IP: {tailScaleIP}</p>
<Button
size="SM"
theme="light"
text= { isDisconnecting ? "Quitting..." : "Quit"}
onClick={handleTailScaleLogout}
disabled={ isDisconnecting === true }
/>
</div>
</div>
)}
{tailScaleConnectionState === "closed" && (
<div className="text-sm text-red-600 dark:text-red-400">
<p>Connect fail, please retry</p>
</div>
)}
</div>
<div className="space-y-4">
{/* Add ZeroTier settings item */}
<SettingsItem
title="ZeroTier"
badge="Experimental"
description="Connect to ZeroTier VPN network"
>
</SettingsItem>
</div>
<div className="space-y-4">
{zeroTierConnectionState === "connecting" && (
<div className="text-sm text-slate-700 dark:text-slate-300">
<p>Connecting...</p>
</div>
)}
{zeroTierConnectionState === "connected" && (
<div className="space-y-4">
<div className="flex items-center gap-x-2 justify-between">
<p>Network ID: {zeroTierNetworkID}</p>
<Button
size="SM"
theme="light"
text="Quit"
onClick={handleZeroTierLogout}
/>
</div>
</div>
)}
{zeroTierConnectionState === "logined" && (
<div className="space-y-4">
<div className="flex items-center gap-x-2 justify-between">
<p>Network ID: {zeroTierNetworkID}</p>
<Button
size="SM"
theme="light"
text="Quit"
onClick={handleZeroTierLogout}
/>
</div>
<div className="flex items-center gap-x-2 justify-between">
<p>Network IP: {zeroTierIP}</p>
</div>
</div>
)}
{zeroTierConnectionState === "closed" && (
<div className="flex items-center gap-x-2 justify-between">
<p>Connect fail, please retry</p>
<Button
size="SM"
theme="light"
text="Retry"
onClick={handleZeroTierLogout}
/>
</div>
)
}
</div>
<div className="space-y-4">
{(zeroTierConnectionState === "disconnected") && (
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Network ID"
value={tempNetworkID}
onChange={handleZeroTierNetworkIdChange}
placeholder="Enter ZeroTier Network ID"
/>
<Button
size="SM"
theme="light"
text="Join in"
onClick={handleZeroTierLogin}
/>
</div>
)}
</div>
<div className="space-y-4">
<SettingsItem
title="Frp"
description="Connect to Frp Server"
/>
<div className="space-y-4">
<TextAreaWithLabel
label="Edit frpc.toml"
placeholder="Enter frpc settings"
value={frpcToml || ""}
rows={3}
onChange={e => setFrpcToml(e.target.value)}
/>
<div className="flex items-center gap-x-2">
{frpcStatus.running ? (
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="danger"
text="Stop frpc"
onClick={handleStopFrpc}
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
<SettingsItem
title=""
description={$at("TailScale use xEdge server")}
>
<Checkbox disabled={tailScaleConnectionState !== "disconnected"}
checked={tailScaleXEdge}
onChange={e => {
if (tailScaleConnectionState !== "disconnected") {
notifications.error("TailScale is running and this setting cannot be modified");
return;
}
handleTailScaleXEdgeChange(e.target.checked);
}}
/>
</SettingsItem>
{tailScaleConnectionState === "connecting" && (
<div className="flex items-center justify-between gap-x-2">
<p>Connecting...</p>
<Button
size="SM"
theme="light"
text={$at("Cancel")}
onClick={handleTailScaleCancel}
/>
</div>
)}
{tailScaleConnectionState === "connected" && (
<div className="space-y-4">
<div className="flex items-center gap-x-2 justify-between">
{tailScaleLoginUrl && (
<p>{$at("Login URL:")} <a href={tailScaleLoginUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400">LoginUrl</a></p>
)}
{!tailScaleLoginUrl && (
<p>{$at("Wait to obtain the Login URL")}</p>
)}
<Button
size="SM"
theme="light"
text= { isDisconnecting ? $at("Quitting...") : $at("Quit")}
onClick={handleTailScaleLogout}
disabled={ isDisconnecting === true }
/>
</div>
</div>
)}
{tailScaleConnectionState === "logined" && (
<div className="space-y-4">
<div className="flex-1 space-y-2">
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network IP")}
</span>
<span className="text-right text-sm font-medium">
{tailScaleIP}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="light"
text= { isDisconnecting ? $at("Quitting...") : $at("Quit")}
onClick={handleTailScaleLogout}
disabled={ isDisconnecting === true }
/>
</div>
</div>
</div>
)}
{tailScaleConnectionState === "closed" && (
<div className="text-sm text-red-600 dark:text-red-400">
<p>Connect fail, please retry</p>
</div>
)}
{ ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
<Button
size="SM"
theme="light"
text="Log"
onClick={handleGetFrpcLog}
text={$at("Enable")}
onClick={handleTailScaleLogin}
/>
</div>
) : (
<Button
size="SM"
theme="primary"
text="Start frpc"
onClick={handleStartFrpc}
/>
)}
)}
</div>
</div>
</div>
</div>
</GridCard>
</AutoHeight>
<div className="space-y-4">
{/* Add ZeroTier settings item */}
<SettingsItem
title="ZeroTier"
badge="Experimental"
description={$at("Connect to ZeroTier VPN network")}
>
</SettingsItem>
</div>
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
{zeroTierConnectionState === "connecting" && (
<div className="text-sm text-slate-700 dark:text-slate-300">
<p>{$at("Connecting...")}</p>
</div>
)}
{zeroTierConnectionState === "connected" && (
<div className="flex-1 space-y-2">
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network ID")}
</span>
<span className="text-right text-sm font-medium">
{zeroTierNetworkID}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="light"
text={$at("Quit")}
onClick={handleZeroTierLogout}
/>
</div>
</div>
)}
{zeroTierConnectionState === "logined" && (
<div className="flex-1 space-y-2">
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network ID")}
</span>
<span className="text-right text-sm font-medium">
{zeroTierNetworkID}
</span>
</div>
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network IP")}
</span>
<span className="text-right text-sm font-medium">
{zeroTierIP}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="light"
text={$at("Quit")}
onClick={handleZeroTierLogout}
/>
</div>
</div>
)}
{zeroTierConnectionState === "closed" && (
<div className="flex items-center gap-x-2 justify-between">
<p>{$at("Connect fail, please retry")}</p>
<Button
size="SM"
theme="light"
text={$at("Retry")}
onClick={handleZeroTierLogout}
/>
</div>
)}
{(zeroTierConnectionState === "disconnected") && (
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Network ID")}
value={tempNetworkID}
onChange={handleZeroTierNetworkIdChange}
placeholder={$at("Enter ZeroTier Network ID")}
/>
<Button
size="SM"
theme="light"
text={$at("Join in")}
onClick={handleZeroTierLogin}
/>
</div>
)}
</div>
</div>
</GridCard>
</AutoHeight>
<div className="space-y-4">
<SettingsItem
title="EasyTier"
description={$at("Connect to EasyTier server")}
/>
</div>
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
{ easyTierRunningStatus.running ? (
<div className="flex-1 space-y-2">
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network Name")}
</span>
<span className="text-right text-sm font-medium">
{easyTierConfig.name || tempEasyTierNetworkName}
</span>
</div>
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network Secret")}
</span>
<span className="text-right text-sm font-medium">
{easyTierConfig.secret || tempEasyTierNetworkSecret}
</span>
</div>
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
{$at("Network Node")}
</span>
<span className="text-right text-sm font-medium">
{easyTierConfig.node || tempEasyTierNetworkNode}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="danger"
text={$at("Stop")}
onClick={handleStopEasyTier}
/>
<Button
size="SM"
theme="light"
text={$at("Log")}
onClick={handleGetEasyTierLog}
/>
<Button
size="SM"
theme="light"
text={$at("Node Info")}
onClick={handleGetEasyTierNodeInfo}
/>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Network Name")}
value={tempEasyTierNetworkName}
onChange={e => setTempEasyTierNetworkName(e.target.value)}
placeholder={$at("Enter EasyTier Network Name")}
/>
</div>
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Network Secret")}
value={tempEasyTierNetworkSecret}
onChange={e => setTempEasyTierNetworkSecret(e.target.value)}
placeholder={$at("Enter EasyTier Network Secret")}
/>
</div>
<div className="space-y-4">
<SettingsItem
title={$at("Network Node")}
description=""
>
<SelectMenuBasic
size="SM"
value={tempEasyTierNetworkNodeMode}
onChange={e => setTempEasyTierNetworkNodeMode(e.target.value)}
options={[
{ value: "default", label: $at("Default") },
{ value: "custom", label: $at("Custom") },
]}
/>
</SettingsItem>
</div>
{tempEasyTierNetworkNodeMode === "custom" && (
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Network Node")}
value={tempEasyTierNetworkNode}
onChange={e => setTempEasyTierNetworkNode(e.target.value)}
placeholder={$at("Enter EasyTier Network Node")}
/>
</div>
)}
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={$at("Start")}
onClick={handleStartEasyTier}
/>
</div>
</div>
)}
</div>
</div>
</GridCard>
</AutoHeight>
<div className="space-y-4">
<SettingsItem
title="Frp"
description={$at("Connect to Frp server")}
/>
</div>
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
<TextAreaWithLabel
label={$at("Edit frpc.toml")}
placeholder={$at("Enter frpc configuration")}
value={frpcToml || ""}
rows={3}
readOnly={frpcRunningStatus.running}
onChange={e => setFrpcToml(e.target.value)}
/>
<div className="flex items-center gap-x-2">
{ frpcRunningStatus.running ? (
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="danger"
text={$at("Stop")}
onClick={handleStopFrpc}
/>
<Button
size="SM"
theme="light"
text={$at("Log")}
onClick={handleGetFrpcLog}
/>
</div>
) : (
<Button
size="SM"
theme="primary"
text={$at("Start")}
onClick={handleStartFrpc}
/>
)}
</div>
</div>
</div>
</GridCard>
</AutoHeight>
</div>
<LogDialog
open={showEasyTierLogModal}
onClose={() => {
setShowEasyTierLogModal(false);
}}
title="EasyTier Log"
description={easyTierLog}
/>
<LogDialog
open={showEasyTierNodeInfoModal}
onClose={() => {
setShowEasyTierNodeInfoModal(false);
}}
title="EasyTier Node Info"
description={easyTierNodeInfo}
/>
<LogDialog
open={showFrpcLogModal}
onClose={() => {

View File

@@ -6,6 +6,7 @@ import { InputFieldWithLabel } from "@/components/InputField";
import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { useReactAt } from "i18n-auto-extractor/react";
export default function SecurityAccessLocalAuthRoute() {
const { setModalView } = useLocalAuthModalStore();
@@ -28,6 +29,7 @@ export default function SecurityAccessLocalAuthRoute() {
}
export function Dialog({ onClose }: { onClose: () => void }) {
const { $at } = useReactAt();
const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null);
const revalidator = useRevalidator();
@@ -65,17 +67,17 @@ export function Dialog({ onClose }: { onClose: () => void }) {
confirmNewPassword: string,
) => {
if (newPassword !== confirmNewPassword) {
setError("Passwords do not match");
setError($at("Passwords do not match"));
return;
}
if (oldPassword === "") {
setError("Please enter your old password");
setError($at("Please enter your old password"));
return;
}
if (newPassword === "") {
setError("Please enter a new password");
setError($at("Please enter a new password"));
return;
}
@@ -91,17 +93,17 @@ export function Dialog({ onClose }: { onClose: () => void }) {
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while changing the password");
setError(data.error || $at("An error occurred while changing the password"));
}
} catch (error) {
console.error(error);
setError("An error occurred while changing the password");
setError($at("An error occurred while changing the password"));
}
};
const handleDeletePassword = async (password: string) => {
if (password === "") {
setError("Please enter your current password");
setError($at("Please enter your current password"));
return;
}
@@ -113,11 +115,11 @@ export function Dialog({ onClose }: { onClose: () => void }) {
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while disabling the password");
setError(data.error || $at("An error occurred while disabling the password"));
}
} catch (error) {
console.error(error);
setError("An error occurred while disabling the password");
setError($at("An error occurred while disabling the password"));
}
};
@@ -150,24 +152,24 @@ export function Dialog({ onClose }: { onClose: () => void }) {
{modalView === "creationSuccess" && (
<SuccessModal
headline="Password Set Successfully"
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
headline={$at("Password Set Successfully")}
description={$at("You've successfully set up local device protection. Your device is now secure against unauthorized local access.")}
onClose={onClose}
/>
)}
{modalView === "deleteSuccess" && (
<SuccessModal
headline="Password Protection Disabled"
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
headline={$at("Password Protection Disabled")}
description={$at("You've successfully disabled the password protection for local access. Remember, your device is now less secure.")}
onClose={onClose}
/>
)}
{modalView === "updateSuccess" && (
<SuccessModal
headline="Password Updated Successfully"
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
headline={$at("Password Updated Successfully")}
description={$at("You've successfully changed your local device protection password. Make sure to remember your new password for future access.")}
onClose={onClose}
/>
)}
@@ -185,6 +187,7 @@ function CreatePasswordModal({
onCancel: () => void;
error: string | null;
}) {
const { $at } = useReactAt();
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -198,24 +201,24 @@ function CreatePasswordModal({
>
<div>
<h2 className="text-lg font-semibold dark:text-white">
Local Device Protection
{$at("Local Device Protection")}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access.
{$at("Create a password to protect your device from unauthorized local access.")}
</p>
</div>
<InputFieldWithLabel
label="New Password"
label={$at("New Password")}
type="password"
placeholder="Enter a strong password"
placeholder={$at("Enter a strong password")}
value={password}
autoFocus
onChange={e => setPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
label={$at("Confirm New Password")}
type="password"
placeholder="Re-enter your password"
placeholder={$at("Re-enter your password")}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
@@ -224,10 +227,10 @@ function CreatePasswordModal({
<Button
size="SM"
theme="primary"
text="Secure Device"
text={$at("Secure Device")}
onClick={() => onSetPassword(password, confirmPassword)}
/>
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
<Button size="SM" theme="light" text={$at("Not Now")} onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</form>
@@ -244,6 +247,7 @@ function DeletePasswordModal({
onCancel: () => void;
error: string | null;
}) {
const { $at } = useReactAt();
const [password, setPassword] = useState("");
return (
@@ -251,16 +255,16 @@ function DeletePasswordModal({
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">
Disable Local Device Protection
{$at("Disable Local Device Protection")}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection.
{$at("Enter your current password to disable local device protection.")}
</p>
</div>
<InputFieldWithLabel
label="Current Password"
label={$at("Current Password")}
type="password"
placeholder="Enter your current password"
placeholder={$at("Enter your current password")}
value={password}
onChange={e => setPassword(e.target.value)}
/>
@@ -268,10 +272,10 @@ function DeletePasswordModal({
<Button
size="SM"
theme="danger"
text="Disable Protection"
text={$at("Disable Protection")}
onClick={() => onDeletePassword(password)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
<Button size="SM" theme="light" text={$at("Cancel")} onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
@@ -292,6 +296,7 @@ function UpdatePasswordModal({
onCancel: () => void;
error: string | null;
}) {
const { $at } = useReactAt();
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState("");
@@ -306,31 +311,30 @@ function UpdatePasswordModal({
>
<div>
<h2 className="text-lg font-semibold dark:text-white">
Change Local Device Password
{ $at("Change Local Device Password") }
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device
protection.
{ $at("Enter your current password and a new password to update your local device protection.") }
</p>
</div>
<InputFieldWithLabel
label="Current Password"
label={ $at("Current Password") }
type="password"
placeholder="Enter your current password"
placeholder={ $at("Enter your current password") }
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
/>
<InputFieldWithLabel
label="New Password"
label={ $at("New Password") }
type="password"
placeholder="Enter a new strong password"
placeholder={ $at("Enter a new strong password") }
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
label={ $at("Confirm New Password") }
type="password"
placeholder="Re-enter your new password"
placeholder={ $at("Re-enter your new password") }
value={confirmNewPassword}
onChange={e => setConfirmNewPassword(e.target.value)}
/>
@@ -338,10 +342,10 @@ function UpdatePasswordModal({
<Button
size="SM"
theme="primary"
text="Update Password"
text={ $at("Update Password") }
onClick={() => onUpdatePassword(oldPassword, newPassword, confirmNewPassword)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
<Button size="SM" theme="light" text={ $at("Cancel") } onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</form>
@@ -358,6 +362,7 @@ function SuccessModal({
description: string;
onClose: () => void;
}) {
const { $at } = useReactAt();
return (
<div className="flex w-full max-w-lg flex-col items-start justify-start space-y-4 text-left">
<div className="space-y-4">
@@ -365,7 +370,7 @@ function SuccessModal({
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
<Button size="SM" theme="primary" text={ $at("Close") } onClick={onClose} />
</div>
</div>
);

View File

@@ -13,8 +13,10 @@ import { isOnDevice } from "../main";
import notifications from "../notifications";
import { SettingsItem } from "./devices.$id.settings";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function SettingsAdvancedRoute() {
const { $at }= useReactAt();
const [send] = useJsonRpc();
const [sshKey, setSSHKey] = useState<string>("");
@@ -135,14 +137,14 @@ export default function SettingsAdvancedRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Advanced"
description="Access additional settings for troubleshooting and customization"
title={$at("Advanced")}
description={$at("Access additional settings for troubleshooting and customization")}
/>
<div className="space-y-4">
<SettingsItem
title="Loopback-Only Mode"
description="Restrict web interface access to localhost only (127.0.0.1)"
title={$at("Loopback-Only Mode")}
description={$at("Restrict web interface access to localhost only (127.0.0.1)")}
>
<Checkbox
checked={localLoopbackOnly}
@@ -153,25 +155,25 @@ export default function SettingsAdvancedRoute() {
{isOnDevice && (
<div className="space-y-4">
<SettingsItem
title="SSH Access"
description="Add your SSH public key to enable secure remote access to the device"
title={$at("SSH Access")}
description={$at("Add your SSH public key to enable secure remote access to the device")}
/>
<div className="space-y-4">
<TextAreaWithLabel
label="SSH Public Key"
label={$at("SSH Public Key")}
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder="Enter your SSH public key"
placeholder={$at("Enter your SSH public key")}
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
The default SSH user is <strong>root</strong>.
{$at("The default SSH user is ")} <strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Update SSH Key"
text={$at("Update SSH Key")}
onClick={handleUpdateSSHKey}
/>
</div>
@@ -180,8 +182,8 @@ export default function SettingsAdvancedRoute() {
)}
<SettingsItem
title="Troubleshooting Mode"
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
title={$at("Troubleshooting Mode")}
description={$at("Diagnostic tools and additional controls for troubleshooting and development purposes")}
>
<Checkbox
defaultChecked={settings.debugMode}
@@ -194,27 +196,27 @@ export default function SettingsAdvancedRoute() {
{settings.debugMode && (
<>
<SettingsItem
title="USB Emulation"
description="Control the USB emulation state"
title={$at("USB Emulation")}
description={$at("Control the USB emulation state")}
>
<Button
size="SM"
theme="light"
text={
usbEmulationEnabled ? "Disable USB Emulation" : "Enable USB Emulation"
usbEmulationEnabled ? $at("Disable USB Emulation") : $at("Enable USB Emulation")
}
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
/>
</SettingsItem>
<SettingsItem
title="Reset Configuration"
description="Reset configuration to default. This will log you out."
title={$at("Reset Configuration")}
description={$at("Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.")}
>
<Button
size="SM"
theme="light"
text="Reset Config"
text={$at("Reset Config")}
onClick={() => {
handleResetConfig();
window.location.reload();

View File

@@ -4,8 +4,10 @@ import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function SettingsAppearanceRoute() {
const { $at }= useReactAt();
const [currentTheme, setCurrentTheme] = useState(() => {
return localStorage.theme || "system";
});
@@ -31,18 +33,18 @@ export default function SettingsAppearanceRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Appearance"
description="Customize the look and feel of your KVM interface"
title={$at("Appearance")}
description={$at("Customize the look and feel of your KVM interface")}
/>
<SettingsItem title="Theme" description="Choose your preferred color theme">
<SettingsItem title={$at("Theme")} description={$at("Choose your preferred color theme")}>
<SelectMenuBasic
size="SM"
label=""
value={currentTheme}
options={[
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: $at("System") },
{ value: "light", label: $at("Light") },
{ value: "dark", label: $at("Dark") },
]}
onChange={e => {
setCurrentTheme(e.target.value);

View File

@@ -11,11 +11,13 @@ import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { useDeviceStore } from "../hooks/stores";
import { SettingsItem } from "./devices.$id.settings";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function SettingsGeneralRoute() {
const [send] = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [autoUpdate, setAutoUpdate] = useState(true);
const { $at } = useReactAt();
const currentVersions = useDeviceStore(state => {
const { appVersion, systemVersion } = state;
@@ -45,44 +47,44 @@ export default function SettingsGeneralRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="General"
description="Configure device settings and update preferences"
title={$at("General")}
description={$at("Configure device settings and update preferences")}
/>
<div className="space-y-4">
<div className="space-y-4 pb-2">
<div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem
title="Version"
title={$at("Version")}
description={
currentVersions ? (
<>
App: {currentVersions.appVersion}
{$at("App")}: {currentVersions.appVersion}
<br />
System: {currentVersions.systemVersion}
{$at("System")}: {currentVersions.systemVersion}
</>
) : (
<>
App: Loading...
{$at("App: Loading...")}
<br />
System: Loading...
{$at("System: Loading...")}
</>
)
}
/>
<div>
<Button
<Button className="hidden"
size="SM"
theme="light"
text="Check for Updates"
text={$at("Check for Updates")}
onClick={() => navigateTo("./update")}
/>
</div>
</div>
<div className="hidden space-y-4">
<SettingsItem
title="Auto Update"
description="Automatically update the device to the latest version"
title={$at("Auto Update")}
description={$at("Automatically update the device to the latest version")}
>
<Checkbox
checked={autoUpdate}
@@ -95,5 +97,5 @@ export default function SettingsGeneralRoute() {
</div>
</div>
</div>
);
);
}

View File

@@ -10,8 +10,10 @@ import { Button, LinkButton } from "@/components/Button";
import notifications from "../notifications";
import { UsbEpModeSetting } from "@components/UsbEpModeSetting";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function SettingsHardwareRoute() {
const { $at }= useReactAt();
const [send] = useJsonRpc();
const settings = useSettingsStore();
@@ -139,7 +141,6 @@ export default function SettingsHardwareRoute() {
`Failed to get LED-Green mode: ${resp.error.data || "Unknown error"}`,
);
}
console.log("LED-Green mode:", resp.result);
const result = resp.result as string;
setLedGreenMode(result);
});
@@ -150,7 +151,6 @@ export default function SettingsHardwareRoute() {
`Failed to get LED-Yellow mode: ${resp.error.data || "Unknown error"}`,
);
}
console.log("LED-Yellow mode:", resp.result);
const result = resp.result as string;
setLedYellowMode(result);
});
@@ -159,13 +159,13 @@ export default function SettingsHardwareRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Hardware"
description="Configure display settings and hardware options for your KVM device"
title={$at("Hardware")}
description={$at("Configure display settings and hardware options for your KVM device")}
/>
<div className="space-y-4">
<SettingsItem
title="Display Orientation"
description="Set the orientation of the display"
title={$at("Display Orientation")}
description={$at("Set the orientation of the display")}
>
<SelectMenuBasic
size="SM"
@@ -184,18 +184,18 @@ export default function SettingsHardwareRoute() {
/>
</SettingsItem>
<SettingsItem
title="Display Brightness"
description="Set the brightness of the display"
title={$at("Display Brightness")}
description={$at("Set the brightness of the display")}
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.max_brightness.toString()}
options={[
{ value: "0", label: "Off" },
{ value: "64", label: "Low" },
{ value: "128", label: "Medium" },
{ value: "200", label: "High" },
{ value: "0", label: $at("Off") },
{ value: "64", label: $at("Low") },
{ value: "128", label: $at("Medium") },
{ value: "200", label: $at("High") },
]}
onChange={e => {
settings.backlightSettings.max_brightness = parseInt(e.target.value);
@@ -215,8 +215,8 @@ export default function SettingsHardwareRoute() {
{settings.backlightSettings.max_brightness != 0 && (
<>
<SettingsItem
title="Dim Display After"
description="Set how long to wait before dimming the display"
title={$at("Dim Display After")}
description={$at("Set how long to wait before dimming the display")}
>
<SelectMenuBasic
size="SM"
@@ -237,8 +237,8 @@ export default function SettingsHardwareRoute() {
/>
</SettingsItem>
<SettingsItem
title="Turn off Display After"
description="Period of inactivity before display automatically turns off"
title={$at("Turn off Display After")}
description={$at("Period of inactivity before display automatically turns off")}
>
<SelectMenuBasic
size="SM"
@@ -259,15 +259,15 @@ export default function SettingsHardwareRoute() {
</SettingsItem>
<p className="text-xs text-slate-600 dark:text-slate-400">
The display will wake up when the connection state changes, or when touched.
{$at("The display will wake up when the connection state changes, or when touched.")}
</p>
</>
)}
<SettingsItem
title="Time Zone"
description="Set the time zone for the clock"
title={$at("Time Zone")}
description={$at("Set the time zone for the clock")}
>
</SettingsItem>
<div className="space-y-4">
@@ -281,25 +281,25 @@ export default function SettingsHardwareRoute() {
<Button
size="SM"
theme="light"
text="Set"
text={$at("Set")}
onClick={handleTimeZoneSave}
/>
</div>
</div>
<SettingsItem
title="LED-Green Type"
description="Set the type of system status indicated by the LED-Green"
title={$at("LED-Green Type")}
description={$at("Set the type of system status indicated by the LED-Green")}
>
<SelectMenuBasic
size="SM"
label=""
value={settings.ledGreenMode.toString()}
options={[
{ value: "network-link", label: "network-link" },
{ value: "network-tx", label: "network-tx" },
{ value: "network-rx", label: "network-rx" },
{ value: "kernel-activity", label: "kernel-activity" },
{ value: "network-link", label: $at("network-link") },
{ value: "network-tx", label: $at("network-tx") },
{ value: "network-rx", label: $at("network-rx") },
{ value: "kernel-activity", label: $at("kernel-activity") },
]}
onChange={e => {
settings.ledGreenMode = e.target.value;
@@ -309,18 +309,18 @@ export default function SettingsHardwareRoute() {
</SettingsItem>
<SettingsItem
title="LED-Yellow Type"
description="Set the type of system status indicated by the LED-Yellow"
title={$at("LED-Yellow Type")}
description={$at("Set the type of system status indicated by the LED-Yellow")}
>
<SelectMenuBasic
size="SM"
label=""
value={settings.ledYellowMode.toString()}
options={[
{ value: "network-link", label: "network-link" },
{ value: "network-tx", label: "network-tx" },
{ value: "network-rx", label: "network-rx" },
{ value: "kernel-activity", label: "kernel-activity" },
{ value: "network-link", label: $at("network-link") },
{ value: "network-tx", label: $at("network-tx") },
{ value: "network-rx", label: $at("network-rx") },
{ value: "kernel-activity", label: $at("kernel-activity") },
]}
onChange={e => {
settings.ledYellowMode = e.target.value;

View File

@@ -11,7 +11,10 @@ import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function SettingsKeyboardRoute() {
const { $at }= useReactAt();
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
@@ -33,11 +36,6 @@ export default function SettingsKeyboardRoute() {
}, [keyboardLayout]);
const layoutOptions = Object.entries(layouts).map(([code, language]) => { return { value: code, label: language } })
const ledSyncOptions = [
{ value: "auto", label: "Automatic" },
{ value: "browser", label: "Browser Only" },
{ value: "host", label: "Host Only" },
];
const [send] = useJsonRpc();
@@ -67,15 +65,15 @@ export default function SettingsKeyboardRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Keyboard"
description="Configure keyboard settings for your device"
title={$at("Keyboard")}
description={$at("Configure keyboard settings for your device")}
/>
<div className="space-y-4">
{ /* this menu item could be renamed to plain "Keyboard layout" in the future, when also the virtual keyboard layout mappings are being implemented */ }
<SettingsItem
title="Paste text"
description="Keyboard layout of target operating system"
title={$at("Paste text")}
description={$at("Keyboard layout of target operating system")}
>
<SelectMenuBasic
size="SM"
@@ -87,15 +85,15 @@ export default function SettingsKeyboardRoute() {
/>
</SettingsItem>
<p className="text-xs text-slate-600 dark:text-slate-400">
Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in KVM matches the settings in the operating system.
{$at("Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in KVM matches the settings in the operating system.")}
</p>
</div>
<div className="space-y-4">
{ /* this menu item could be renamed to plain "Keyboard layout" in the future, when also the virtual keyboard layout mappings are being implemented */ }
<SettingsItem
title="LED state synchronization"
description="Synchronize the LED state of the keyboard with the target device"
title={$at("LED state synchronization")}
description={$at("Synchronize the LED state of the keyboard with the target device")}
>
<SelectMenuBasic
size="SM"
@@ -103,15 +101,19 @@ export default function SettingsKeyboardRoute() {
fullWidth
value={keyboardLedSync}
onChange={e => setKeyboardLedSync(e.target.value as KeyboardLedSync)}
options={ledSyncOptions}
options={[
{ value: "auto", label: $at("Auto") },
{ value: "browser", label: $at("Browser Only") },
{ value: "host", label: $at("Host Only") },
]}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Show Pressed Keys"
description="Display currently pressed keys in the status bar"
title={$at("Show Pressed Keys")}
description={$at("Display currently pressed keys in the status bar")}
>
<Checkbox
checked={showPressedKeys}

View File

@@ -6,8 +6,10 @@ import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { MacroForm } from "@/components/MacroForm";
import { DEFAULT_DELAY } from "@/constants/macros";
import notifications from "@/notifications";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function SettingsMacrosAddRoute() {
const { $at }= useReactAt();
const { macros, saveMacros } = useMacrosStore();
const [isSaving, setIsSaving] = useState(false);
const navigate = useNavigate();
@@ -46,8 +48,8 @@ export default function SettingsMacrosAddRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Add New Macro"
description="Create a new keyboard macro"
title={$at("Add New Macro")}
description={$at("Create a new keyboard macro")}
/>
<MacroForm
initialData={{

View File

@@ -8,6 +8,7 @@ import { MacroForm } from "@/components/MacroForm";
import notifications from "@/notifications";
import { Button } from "@/components/Button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import {useReactAt} from 'i18n-auto-extractor/react'
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({
@@ -17,6 +18,7 @@ const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
};
export default function SettingsMacrosEditRoute() {
const { $at }= useReactAt();
const { macros, saveMacros } = useMacrosStore();
const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -95,13 +97,13 @@ export default function SettingsMacrosEditRoute() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<SettingsPageHeader
title="Edit Macro"
description="Modify your keyboard macro"
title={$at("Edit Macro")}
description={$at("Modify your keyboard macro")}
/>
<Button
size="SM"
theme="light"
text="Delete Macro"
text={$at("Delete Macro")}
className="text-red-500 dark:text-red-400"
LeadingIcon={LuTrash2}
onClick={() => setShowDeleteConfirm(true)}
@@ -113,7 +115,7 @@ export default function SettingsMacrosEditRoute() {
onSubmit={handleUpdateMacro}
onCancel={() => navigate("../")}
isSubmitting={isUpdating}
submitText="Save Changes"
/>
<ConfirmDialog
@@ -122,7 +124,8 @@ export default function SettingsMacrosEditRoute() {
title="Delete Macro"
description="Are you sure you want to delete this macro? This action cannot be undone."
variant="danger"
confirmText={isDeleting ? "Deleting" : "Delete"}
confirmText={isDeleting ? $at("Deleting") : $at("Delete")}
cancelText={$at("Cancel")}
onConfirm={() => {
handleDeleteMacro();
setShowDeleteConfirm(false);

View File

@@ -21,6 +21,7 @@ import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
import notifications from "@/notifications";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import LoadingSpinner from "@/components/LoadingSpinner";
import {useReactAt} from 'i18n-auto-extractor/react'
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({
@@ -30,6 +31,7 @@ const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
};
export default function SettingsMacrosRoute() {
const { $at }= useReactAt();
const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
const navigate = useNavigate();
const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
@@ -223,7 +225,7 @@ export default function SettingsMacrosRoute() {
</>
) : (
<span className="font-medium text-slate-500 dark:text-slate-400">
Delay only
{$at("Delay Only")}
</span>
)}
{step.delay !== DEFAULT_DELAY && (
@@ -264,7 +266,7 @@ export default function SettingsMacrosRoute() {
size="XS"
theme="light"
LeadingIcon={LuPenLine}
text="Edit"
text={$at("Edit")}
onClick={() => navigate(`${macro.id}/edit`)}
disabled={actionLoadingId === macro.id}
aria-label={`Edit macro ${macro.name}`}
@@ -280,10 +282,11 @@ export default function SettingsMacrosRoute() {
setShowDeleteConfirm(false);
setMacroToDelete(null);
}}
title="Delete Macro"
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
title={$at("Delete Macro")}
description={`${$at("Are you sure you want to delete")} "${macroToDelete?.name}" ${$at("? This action cannot be undone.")}`}
variant="danger"
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
confirmText={actionLoadingId === macroToDelete?.id ? $at("Deleting...") : $at("Delete")}
cancelText={$at("Cancel")}
onConfirm={handleDeleteMacro}
isConfirming={actionLoadingId === macroToDelete?.id}
/>
@@ -306,18 +309,18 @@ export default function SettingsMacrosRoute() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<SettingsPageHeader
title="Keyboard Macros"
description={`Combine keystrokes into a single action for faster workflows.`}
title={$at("Keyboard Macros")}
description={$at("Combine keystrokes into a single action for faster workflows.")}
/>
{macros.length > 0 && (
<div className="flex items-center pl-2">
<Button
size="SM"
theme="primary"
text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
text={isMaxMacrosReached ? $at("Max Reached") : $at("Add New Macro")}
onClick={() => navigate("add")}
disabled={isMaxMacrosReached}
aria-label="Add new macro"
aria-label={$at("Add new macro")}
/>
</div>
)}
@@ -327,7 +330,7 @@ export default function SettingsMacrosRoute() {
{loading && macros.length === 0 ? (
<EmptyCard
IconElm={LuCommand}
headline="Loading macros..."
headline={$at("Loading macros...")}
BtnElm={
<div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
@@ -337,16 +340,16 @@ export default function SettingsMacrosRoute() {
) : macros.length === 0 ? (
<EmptyCard
IconElm={LuCommand}
headline="Create Your First Macro"
description="Combine keystrokes into a single action"
headline={$at("Create Your First Macro")}
description={$at("Combine keystrokes into a single action")}
BtnElm={
<Button
size="SM"
theme="primary"
text="Add New Macro"
text={$at("Add New Macro")}
onClick={() => navigate("add")}
disabled={isMaxMacrosReached}
aria-label="Add new macro"
aria-label={$at("Add new macro")}
/>
}
/>

View File

@@ -16,7 +16,10 @@ import { cx } from "../cva.config";
import { SettingsItem } from "./devices.$id.settings";
import {useReactAt} from 'i18n-auto-extractor/react'
export default function SettingsMouseRoute() {
const { $at } = useReactAt();
const hideCursor = useSettingsStore(state => state.isCursorHidden);
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
@@ -64,14 +67,14 @@ export default function SettingsMouseRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Mouse"
description="Configure cursor behavior and interaction settings for your device"
title={$at("Mouse")}
description={$at("Configure cursor behavior and interaction settings for your device")}
/>
<div className="space-y-4">
<SettingsItem
title="Hide Cursor"
description="Hide the cursor when sending mouse movements"
title={$at("Hide Cursor")}
description={$at("Hide the cursor when sending mouse movements")}
>
<Checkbox
checked={hideCursor}
@@ -80,8 +83,8 @@ export default function SettingsMouseRoute() {
</SettingsItem>
<SettingsItem
title="Scroll Throttling"
description="Reduce the frequency of scroll events"
title={$at("Scroll Throttling")}
description={$at("Reduce the frequency of scroll events")}
>
<SelectMenuBasic
size="SM"
@@ -95,8 +98,8 @@ export default function SettingsMouseRoute() {
</SettingsItem>
<SettingsItem
title="Jiggler"
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
title={$at("Jiggler")}
description={$at("Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating")}
>
<Checkbox
checked={jiggler}
@@ -104,7 +107,7 @@ export default function SettingsMouseRoute() {
/>
</SettingsItem>
<div className="space-y-4">
<SettingsItem title="Modes" description="Choose the mouse input mode" />
<SettingsItem title={$at("Modes")} description={$at("Choose the mouse input mode")} />
<div className="flex items-center gap-4">
<button
className="group block grow"
@@ -122,10 +125,10 @@ export default function SettingsMouseRoute() {
<div className="flex grow items-center justify-between">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Absolute
{ $at("Absolute") }
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most convenient
{ $at("Most convenient") }
</p>
</div>
<CheckCircleIcon
@@ -154,10 +157,10 @@ export default function SettingsMouseRoute() {
<div className="flex grow items-center justify-between">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Relative
{ $at("Relative") }
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most Compatible
{ $at("Most Compatible") }
</p>
</div>
<CheckCircleIcon

View File

@@ -29,6 +29,7 @@ import AutoHeight from "../components/AutoHeight";
import DhcpLeaseCard from "../components/DhcpLeaseCard";
import { SettingsItem } from "./devices.$id.settings";
import {useReactAt} from 'i18n-auto-extractor/react'
dayjs.extend(relativeTime);
@@ -71,6 +72,7 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
}
export default function SettingsNetworkRoute() {
const { $at } = useReactAt();
const [send] = useJsonRpc();
const [networkState, setNetworkState] = useNetworkStateStore(state => [
state,
@@ -215,13 +217,13 @@ export default function SettingsNetworkRoute() {
<>
<Fieldset disabled={!networkSettingsLoaded} className="space-y-4">
<SettingsPageHeader
title="Network"
description="Configure your network settings"
title={$at("Network")}
description={$at("Configure your network settings")}
/>
<div className="space-y-4">
<SettingsItem
title="MAC Address"
description="Hardware identifier for the network interface"
title={$at("MAC Address")}
description={$at("Hardware identifier for the network interface")}
>
<InputField
type="text"
@@ -236,7 +238,7 @@ export default function SettingsNetworkRoute() {
<div className="space-y-4">
<SettingsItem
title="Hostname"
description="Device identifier on the network. Blank for system default"
description={$at("Device identifier on the network. Blank for system default")}
>
<div className="relative">
<div>
@@ -258,8 +260,8 @@ export default function SettingsNetworkRoute() {
<div className="space-y-4">
<div className="space-y-1">
<SettingsItem
title="Domain"
description="Network domain suffix for the device"
title={$at("Domain")}
description={$at("Device domain suffix in mDNS network")}
>
<div className="space-y-2">
<SelectMenuBasic
@@ -279,7 +281,7 @@ export default function SettingsNetworkRoute() {
<InputFieldWithLabel
size="SM"
type="text"
label="Custom Domain"
label={$at("Custom Domain")}
placeholder="home"
value={customDomain}
onChange={e => {
@@ -293,7 +295,7 @@ export default function SettingsNetworkRoute() {
<div className="space-y-4">
<SettingsItem
title="mDNS"
description="Control mDNS (multicast DNS) operational mode"
description={$at("Control mDNS (multicast DNS) operational mode")}
>
<SelectMenuBasic
size="SM"
@@ -311,8 +313,8 @@ export default function SettingsNetworkRoute() {
<div className="space-y-4">
<SettingsItem
title="Time synchronization"
description="Configure time synchronization settings"
title={$at("Time synchronization")}
description={$at("Configure time synchronization settings")}
>
<SelectMenuBasic
size="SM"
@@ -336,7 +338,7 @@ export default function SettingsNetworkRoute() {
size="SM"
theme="primary"
disabled={firstNetworkSettings.current === networkSettings}
text="Save Settings"
text={$at("Save settings")}
onClick={() => setNetworkSettingsRemote(networkSettings)}
/>
</div>
@@ -344,7 +346,7 @@ export default function SettingsNetworkRoute() {
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="space-y-4">
<SettingsItem title="IPv4 Mode" description="Configure the IPv4 mode">
<SettingsItem title={$at("IPv4 Mode")} description={$at("Configure IPv4 mode")}>
<SelectMenuBasic
size="SM"
value={networkSettings.ipv4_mode}
@@ -361,7 +363,7 @@ export default function SettingsNetworkRoute() {
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
{$at("DHCP Lease Information")}
</h3>
<div className="animate-pulse space-y-3">
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
@@ -379,14 +381,14 @@ export default function SettingsNetworkRoute() {
) : (
<EmptyCard
IconElm={LuEthernetPort}
headline="DHCP Information"
description="No DHCP lease information available"
headline={$at("DHCP Information")}
description={$at("No DHCP lease information available")}
/>
)}
</AutoHeight>
</div>
<div className="space-y-4">
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode">
<SettingsItem title={$at("IPv6 Mode")} description={$at("Configure the IPv6 mode")}>
<SelectMenuBasic
size="SM"
value={networkSettings.ipv6_mode}
@@ -423,8 +425,8 @@ export default function SettingsNetworkRoute() {
) : (
<EmptyCard
IconElm={LuEthernetPort}
headline="IPv6 Information"
description="No IPv6 addresses configured"
headline={$at("IPv6 Information")}
description={$at("No IPv6 addresses configured")}
/>
)}
</AutoHeight>
@@ -450,10 +452,11 @@ export default function SettingsNetworkRoute() {
<ConfirmDialog
open={showRenewLeaseConfirm}
onClose={() => setShowRenewLeaseConfirm(false)}
title="Renew DHCP Lease"
description="This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process."
title={$at("Renew DHCP Lease")}
description={$at("This will request your DHCP server to assign a new IP address. Your device may lose network connectivity during the process.")}
variant="danger"
confirmText="Renew Lease"
confirmText={$at("Renew DHCP Lease")}
cancelText={$at("Cancel")}
onConfirm={() => {
handleRenewLease();
setShowRenewLeaseConfirm(false);

View File

@@ -24,6 +24,7 @@ import useKeyboard from "@/hooks/useKeyboard";
import { FeatureFlag } from "../components/FeatureFlag";
import { cx } from "../cva.config";
import {useReactAt} from 'i18n-auto-extractor/react'
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
export default function SettingsRoute() {
@@ -34,6 +35,7 @@ export default function SettingsRoute() {
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const { width = 0 } = useResizeObserver({ ref: scrollContainerRef as React.RefObject<HTMLDivElement> });
const {setCurrentLang,$at,langSet}= useReactAt()
// Handle scroll position to show/hide gradients
const handleScroll = () => {
@@ -92,7 +94,7 @@ export default function SettingsRoute() {
to=".."
size="SM"
theme="blank"
text="Back to KVM"
text={$at("Back to KVM")}
LeadingIcon={LuArrowLeft}
textAlign="left"
/>
@@ -102,7 +104,7 @@ export default function SettingsRoute() {
to=".."
size="SM"
theme="blank"
text="Back to KVM"
text={$at("Back to KVM")}
LeadingIcon={LuArrowLeft}
textAlign="left"
fullWidth
@@ -141,7 +143,7 @@ export default function SettingsRoute() {
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuSettings className="h-4 w-4 shrink-0" />
<h1>General</h1>
<h1>{$at("General")}</h1>
</div>
</NavLink>
</div>
@@ -153,11 +155,11 @@ export default function SettingsRoute() {
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuMouse className="h-4 w-4 shrink-0" />
<h1>Mouse</h1>
<h1>{$at("Mouse")}</h1>
</div>
</NavLink>
</div>
<FeatureFlag minAppVersion="0.4.0" name="Paste text">
<div className="shrink-0">
<NavLink
to="keyboard"
@@ -165,11 +167,11 @@ export default function SettingsRoute() {
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuKeyboard className="h-4 w-4 shrink-0" />
<h1>Keyboard</h1>
<h1>{$at("Keyboard")}</h1>
</div>
</NavLink>
</div>
</FeatureFlag>
<div className="shrink-0">
<NavLink
to="video"
@@ -177,7 +179,7 @@ export default function SettingsRoute() {
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuVideo className="h-4 w-4 shrink-0" />
<h1>Video</h1>
<h1>{$at("Video")}</h1>
</div>
</NavLink>
</div>
@@ -188,7 +190,7 @@ export default function SettingsRoute() {
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuCpu className="h-4 w-4 shrink-0" />
<h1>Hardware</h1>
<h1>{$at("Hardware")}</h1>
</div>
</NavLink>
</div>
@@ -199,7 +201,7 @@ export default function SettingsRoute() {
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuShieldCheck className="h-4 w-4 shrink-0" />
<h1>Access</h1>
<h1>{$at("Access")}</h1>
</div>
</NavLink>
</div>
@@ -210,7 +212,7 @@ export default function SettingsRoute() {
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuPalette className="h-4 w-4 shrink-0" />
<h1>Appearance</h1>
<h1>{$at("Appearance")}</h1>
</div>
</NavLink>
</div>
@@ -221,7 +223,7 @@ export default function SettingsRoute() {
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuCommand className="h-4 w-4 shrink-0" />
<h1>Keyboard Macros</h1>
<h1>{$at("Keyboard Macros")}</h1>
</div>
</NavLink>
</div>
@@ -232,7 +234,7 @@ export default function SettingsRoute() {
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuNetwork className="h-4 w-4 shrink-0" />
<h1>Network</h1>
<h1>{$at("Network")}</h1>
</div>
</NavLink>
</div>
@@ -243,7 +245,7 @@ export default function SettingsRoute() {
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuWrench className="h-4 w-4 shrink-0" />
<h1>Advanced</h1>
<h1>{$at("Advanced")}</h1>
</div>
</NavLink>
</div>

View File

@@ -10,6 +10,8 @@ import notifications from "../notifications";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
import {useReactAt} from 'i18n-auto-extractor/react'
const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [
@@ -41,6 +43,7 @@ const streamQualityOptions = [
];
export default function SettingsVideoRoute() {
const { $at }= useReactAt();
const [send] = useJsonRpc();
const [streamQuality, setStreamQuality] = useState("1");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
@@ -117,15 +120,15 @@ export default function SettingsVideoRoute() {
<div className="space-y-3">
<div className="space-y-4">
<SettingsPageHeader
title="Video"
description="Configure display settings and EDID for optimal compatibility"
title={$at("Video")}
description={$at("Configure display settings and EDID for optimal compatibility")}
/>
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem
title="Stream Quality"
description="Adjust the quality of the video stream"
title={$at("Stream Quality")}
description={$at("Adjust the quality of the video stream")}
>
<SelectMenuBasic
size="SM"
@@ -138,14 +141,14 @@ export default function SettingsVideoRoute() {
{/* Video Enhancement Settings */}
<SettingsItem
title="Video Enhancement"
description="Adjust color settings to make the video output more vibrant and colorful"
title={$at("Video Enhancement")}
description={$at("Adjust color settings to make the video output more vibrant and colorful")}
/>
<div className="space-y-4 pl-4">
<SettingsItem
title="Saturation"
description={`Color saturation (${videoSaturation.toFixed(1)}x)`}
title={$at("Saturation")}
description={`${$at("Color saturation")} (${videoSaturation.toFixed(1)}x)`}
>
<input
type="range"
@@ -159,8 +162,8 @@ export default function SettingsVideoRoute() {
</SettingsItem>
<SettingsItem
title="Brightness"
description={`Brightness level (${videoBrightness.toFixed(1)}x)`}
title={$at("Brightness")}
description={`${$at("Brightness level")} (${videoBrightness.toFixed(1)}x)`}
>
<input
type="range"
@@ -174,8 +177,8 @@ export default function SettingsVideoRoute() {
</SettingsItem>
<SettingsItem
title="Contrast"
description={`Contrast level (${videoContrast.toFixed(1)}x)`}
title={$at("Contrast")}
description={`${$at("Contrast level")} (${videoContrast.toFixed(1)}x)`}
>
<input
type="range"
@@ -192,7 +195,7 @@ export default function SettingsVideoRoute() {
<Button
size="SM"
theme="light"
text="Reset to Default"
text={$at("Reset to Default")}
onClick={() => {
setVideoSaturation(1.0);
setVideoBrightness(1.0);
@@ -204,7 +207,7 @@ export default function SettingsVideoRoute() {
<SettingsItem
title="EDID"
description="Adjust the EDID settings for the display"
description={$at("Adjust the EDID settings for the display")}
>
<SelectMenuBasic
size="SM"
@@ -226,11 +229,11 @@ export default function SettingsVideoRoute() {
{customEdidValue !== null && (
<>
<SettingsItem
title="Custom EDID"
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
title={$at("Custom EDID")}
description={$at("EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments.")}
/>
<TextAreaWithLabel
label="EDID File"
label={$at("EDID File")}
placeholder="00F..."
rows={3}
value={customEdidValue}
@@ -240,13 +243,13 @@ export default function SettingsVideoRoute() {
<Button
size="SM"
theme="primary"
text="Set Custom EDID"
text={$at("Set Custom EDID")}
onClick={() => handleEDIDChange(customEdidValue)}
/>
<Button
size="SM"
theme="light"
text="Restore to default"
text={$at("Restore to default")}
onClick={() => {
setCustomEdidValue(null);
handleEDIDChange(defaultEdid);

View File

@@ -1,5 +1,5 @@
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { useState } from "react";
import { useState, useEffect } from "react";
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
@@ -10,6 +10,9 @@ import { DEVICE_API } from "@/ui.config";
import { GridCard } from "../components/Card";
import { cx } from "../cva.config";
import api from "../api";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import {useReactAt} from 'i18n-auto-extractor/react'
import DashboardNavbar from "@/components/Header";
import { DeviceStatus } from "./welcome-local";
@@ -47,6 +50,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
};
export default function WelcomeLocalModeRoute() {
const { $at }= useReactAt();
const actionData = useActionData() as { error?: string };
const [selectedMode, setSelectedMode] = useState<"password" | "noPassword" | null>(
null,
@@ -54,6 +58,12 @@ export default function WelcomeLocalModeRoute() {
return (
<>
<DashboardNavbar
primaryLinks={[]}
showConnectionStatus={false}
isLoggedIn={false}
kvmName={"PicoKVM Device"}
/>
<GridBackground />
<div className="grid min-h-screen">
<Container>
@@ -73,10 +83,10 @@ export default function WelcomeLocalModeRoute() {
style={{ animationDelay: "200ms" }}
>
<h1 className="text-4xl font-semibold text-black dark:text-white">
Local Authentication Method
{($at("Local Authentication Method"))}
</h1>
<p className="font-medium text-slate-600 dark:text-slate-400">
Select how you{"'"}d like to secure your KVM device locally.
{($at("Select how you would like to secure your KVM device locally."))}
</p>
</div>
@@ -101,11 +111,19 @@ export default function WelcomeLocalModeRoute() {
<h3 className="text-base font-bold text-black dark:text-white">
{mode === "password" ? "Password protected" : "No Password"}
</h3>
<h3 className="text-base font-bold text-black dark:text-white">
{mode === "password" ? "密码保护" : "无密码"}
</h3>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
{mode === "password"
? "Secure your device with a password for added protection."
: "Quick access without password authentication."}
</p>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
{mode === "password"
? "设置密码保护您的设备安全"
: "无需密码快速访问"}
</p>
</div>
<input
type="radio"
@@ -140,7 +158,7 @@ export default function WelcomeLocalModeRoute() {
theme="primary"
fullWidth
type="submit"
text="Continue"
text={$at("Continue")}
textAlign="center"
disabled={!selectedMode}
/>
@@ -151,7 +169,7 @@ export default function WelcomeLocalModeRoute() {
className="animate-fadeIn mx-auto max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400"
style={{ animationDelay: "600ms" }}
>
You can always change your authentication method later in the settings.
{($at("You can always change your authentication method later in the settings."))}
</p>
</div>
</div>

View File

@@ -11,6 +11,8 @@ import LogoLuckfox from "@/assets/logo-luckfox.png";
import { DEVICE_API } from "@/ui.config";
import api from "../api";
import { useReactAt } from 'i18n-auto-extractor/react'
import DashboardNavbar from "@/components/Header";
import { DeviceStatus } from "./welcome-local";
@@ -50,6 +52,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
};
export default function WelcomeLocalPasswordRoute() {
const { $at }= useReactAt();
const actionData = useActionData() as { error?: string };
const [showPassword, setShowPassword] = useState(false);
const passwordInputRef = useRef<HTMLInputElement>(null);
@@ -65,6 +68,12 @@ export default function WelcomeLocalPasswordRoute() {
return (
<>
<DashboardNavbar
primaryLinks={[]}
showConnectionStatus={false}
isLoggedIn={false}
kvmName={"PicoKVM Device"}
/>
<GridBackground />
<div className="grid min-h-screen">
<Container>
@@ -84,10 +93,10 @@ export default function WelcomeLocalPasswordRoute() {
style={{ animationDelay: "200ms" }}
>
<h1 className="text-4xl font-semibold text-black dark:text-white">
Set a Password
{$at("Set a Password")}
</h1>
<p className="font-medium text-slate-600 dark:text-slate-400">
Create a strong password to secure your KVM device locally.
{$at("Create a strong password to secure your KVM device locally.")}
</p>
</div>
@@ -99,10 +108,10 @@ export default function WelcomeLocalPasswordRoute() {
style={{ animationDelay: "400ms" }}
>
<InputFieldWithLabel
label="Password"
label={$at("Password")}
type={showPassword ? "text" : "password"}
name="password"
placeholder="Enter a password"
placeholder={$at("Enter a password")}
autoComplete="new-password"
ref={passwordInputRef}
TrailingElm={
@@ -129,11 +138,11 @@ export default function WelcomeLocalPasswordRoute() {
style={{ animationDelay: "400ms" }}
>
<InputFieldWithLabel
label="Confirm Password"
label={$at("Confirm Password")}
autoComplete="new-password"
type={showPassword ? "text" : "password"}
name="confirmPassword"
placeholder="Confirm your password"
placeholder={$at("Confirm your password")}
error={actionData?.error}
/>
</div>
@@ -150,7 +159,7 @@ export default function WelcomeLocalPasswordRoute() {
theme="primary"
fullWidth
type="submit"
text="Set Password"
text={$at("Set Password")}
textAlign="center"
/>
</div>
@@ -161,15 +170,15 @@ export default function WelcomeLocalPasswordRoute() {
className="animate-fadeIn max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400"
style={{ animationDelay: "800ms" }}
>
This password will be used to secure your device data and protect against
unauthorized access.{" "}
<span className="font-bold">All data remains on your local device.</span>
{$at("This password will be used to secure your device data and protect against unauthorized access.")}{" "}
<span className="font-bold">{$at("All data remains on your local device.")}</span>
</p>
</div>
</div>
</Container>
</div>
</>
);
}

4
usb.go
View File

@@ -50,6 +50,10 @@ func initUsbGadget() {
}
func initSystemInfo() {
if !config.AutoMountSystemInfo {
return
}
go func() {
for {
if !networkState.HasIPAssigned() {

131
vpn.go
View File

@@ -18,7 +18,7 @@ type TailScaleSettings struct {
XEdge bool `json:"xEdge"`
}
func rpcCanelTailScale() error {
func rpcCancelTailScale() error {
_, err := CallVpnCtrlAction("cancel_tailscale", map[string]interface{}{"type": "no_param"})
if err != nil {
return err
@@ -381,6 +381,129 @@ func rpcGetFrpcStatus() (FrpcStatus, error) {
return FrpcStatus{Running: frpcRunning()}, nil
}
type EasytierStatus struct {
Running bool `json:"running"`
}
type EasytierConfig struct {
Name string `json:"name"`
Secret string `json:"secret"`
Node string `json:"node"`
}
var (
easytierLogPath = "/tmp/easytier.log"
)
func easytierRunning() bool {
cmd := exec.Command("pgrep", "-x", "easytier-core")
return cmd.Run() == nil
}
func rpcGetEasyTierLog() (string, error) {
f, err := os.Open(easytierLogPath)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("easytier log file not exist")
}
return "", err
}
defer f.Close()
const want = 30
lines := make([]string, 0, want+10)
sc := bufio.NewScanner(f)
for sc.Scan() {
lines = append(lines, sc.Text())
if len(lines) > want {
lines = lines[1:]
}
}
if err := sc.Err(); err != nil {
return "", err
}
var buf []byte
for _, l := range lines {
buf = append(buf, l...)
buf = append(buf, '\n')
}
return string(buf), nil
}
func rpcGetEasyTierNodeInfo() (string, error) {
cmd := exec.Command("easytier-cli", "node")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get easytier node info: %w", err)
}
return string(output), nil
}
func rpcGetEasyTierConfig() (EasytierConfig, error) {
return config.EasytierConfig, nil
}
func rpcStartEasyTier(name, secret, node string) error {
if easytierRunning() {
_ = exec.Command("pkill", "-x", "easytier-core").Run()
}
if name == "" || secret == "" || node == "" {
return fmt.Errorf("easytier config is invalid")
}
cmd := exec.Command("easytier-core", "-d", "--network-name", name, "--network-secret", secret, "-p", node)
cmd.Stdout = nil
cmd.Stderr = nil
logFile, err := os.OpenFile(easytierLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("failed to open easytier log file: %w", err)
}
defer logFile.Close()
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start easytier failed: %w", err)
} else {
config.EasytierAutoStart = true
config.EasytierConfig = EasytierConfig{
Name: name,
Secret: secret,
Node: node,
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
}
return nil
}
func rpcStopEasyTier() error {
if easytierRunning() {
err := exec.Command("pkill", "-x", "easytier-core").Run()
if err != nil {
return fmt.Errorf("failed to stop easytier: %w", err)
}
}
config.EasytierAutoStart = false
err := SaveConfig()
if err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetEasyTierStatus() (EasytierStatus, error) {
return EasytierStatus{Running: easytierRunning()}, nil
}
func initVPN() {
waitVpnCtrlClientConnected()
go func() {
@@ -411,6 +534,12 @@ func initVPN() {
vpnLogger.Error().Err(err).Msg("Failed to auto start frpc")
}
}
if config.EasytierAutoStart && config.EasytierConfig.Name != "" && config.EasytierConfig.Secret != "" && config.EasytierConfig.Node != "" {
if err := rpcStartEasyTier(config.EasytierConfig.Name, config.EasytierConfig.Secret, config.EasytierConfig.Node); err != nil {
vpnLogger.Error().Err(err).Msg("Failed to auto start easytier")
}
}
}()
go func() {