Compare commits

...

10 Commits
2.3.3 ... 2.3.4

Author SHA1 Message Date
wj-xiao
4af32fc703 release: nanokvm@2.3.4 2026-01-26 17:56:52 +08:00
wj-xiao
a611e5ef56 feat: support keyboard leader key functionality 2026-01-26 17:10:16 +08:00
wj-xiao
94051c3037 chore: added image version number v1.4.2 2026-01-26 11:23:17 +08:00
wj-xiao
d5cd706078 fix: compatible with non-standard modifier keys 2026-01-26 11:12:19 +08:00
wj-xiao
7460ebfd07 fix: the menu bar cannot be closed 2026-01-26 10:46:57 +08:00
wj-xiao
e3877308a0 fix: trigger update check when switching preview updates 2026-01-26 10:28:27 +08:00
wenjie
7c28fb8124 Merge pull request #737 from kmw0410/main
update korean translation
2026-01-26 10:08:02 +08:00
wenjie
b18873364f Merge pull request #733 from j796160836/feature/update_zh_tw
Update zh_tw translation
2026-01-26 10:07:37 +08:00
kmw0410
016a7d8a51 update korean translation 2026-01-25 22:04:24 +09:00
Johnny Sung
585f1bc2b5 feat: update zh_tw translation 2026-01-20 01:59:53 +08:00
23 changed files with 918 additions and 195 deletions

View File

@@ -1,3 +1,19 @@
## 2.3.4 (2026-01-26)
### Features
* Introduced `Leader Key` functionality to bypass local browser shortcut interception
### Bug Fixes
* Fixed an issue where the Right Shift key was not being recognized on Windows system
* Fixed a bug where the menu bar would fail to close correctly
### Localization
* Updated Traditional Chinese translations (thanks to [@j796160836](https://github.com/j796160836))
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
## 2.3.3 (2026-01-16)
### Features

View File

@@ -29,3 +29,11 @@ type AddShortcutReq struct {
type DeleteShortcutReq struct {
ID string `validate:"required"`
}
type SetLeaderKeyReq struct {
Key string `validate:"omitempty"`
}
type GetLeaderKeyRsp struct {
Key string `json:"key"`
}

View File

@@ -17,6 +17,9 @@ func hidRouter(r *gin.Engine) {
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key
api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.POST("/hid/mode", service.SetHidMode) // set hid mode
api.POST("/hid/reset", service.ResetHid) // reset hid

View File

@@ -0,0 +1,63 @@
package hid
import (
"os"
"strings"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
LeaderKeyFile = "/etc/kvm/leader-key"
)
func (s *Service) SetLeaderKey(c *gin.Context) {
var req proto.SetLeaderKeyReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
if req.Key == "" {
err := os.Remove(LeaderKeyFile)
if err != nil && !os.IsNotExist(err) {
rsp.ErrRsp(c, -2, "reset failed")
return
}
} else {
err := os.WriteFile(LeaderKeyFile, []byte(req.Key), 0o644)
if err != nil {
rsp.ErrRsp(c, -3, "write failed")
return
}
}
rsp.OkRsp(c)
log.Debugf("set leader key: %s", req.Key)
}
func (s *Service) GetLeaderKey(c *gin.Context) {
var rsp proto.Response
data, err := os.ReadFile(LeaderKeyFile)
if err != nil {
if os.IsNotExist(err) {
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
Key: "",
})
return
}
rsp.ErrRsp(c, -1, "read leader key failed")
return
}
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
Key: strings.Replace(string(data), "\n", "", -1),
})
log.Debugf("get leader key successful")
}

View File

@@ -1,11 +1,12 @@
package vm
import (
"NanoKVM-Server/config"
"fmt"
"os"
"strings"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -19,6 +20,7 @@ var imageVersionMap = map[string]string{
"2024-11-13-09-59-9c961a.img": "v1.3.0",
"2025-02-17-19-08-3649fe.img": "v1.4.0",
"2025-04-17-14-21-98d17d.img": "v1.4.1",
"2026-01-05-1_4_1.img": "v1.4.2",
}
func (s *Service) GetInfo(c *gin.Context) {

View File

@@ -43,3 +43,13 @@ export function deleteShortcut(id: string) {
};
return http.delete('/api/hid/shortcut', data);
}
// get shortcut leader key
export function getLeaderKey() {
return http.get('/api/hid/shortcut/leader-key');
}
// set shortcut leader key
export function setLeaderKey(key: string) {
return http.post('/api/hid/shortcut/leader-key', { key });
}

View File

@@ -15,7 +15,6 @@ export interface MenuVisibilityState {
isMenuExpanded: boolean;
isMenuHidden: boolean;
isMenuMoved: boolean;
isInvisible: boolean;
handleHovered: (hovered: boolean) => void;
handleMoved: () => void;
setIsMenuExpanded: (expanded: boolean) => void;
@@ -76,18 +75,12 @@ export function useMenuVisibility(): MenuVisibilityState {
// Handle display mode changes
useEffect(() => {
if (menuDisplayMode === 'off') {
setIsMenuExpanded(false);
setIsMenuHidden(false);
stopCountdown();
} else if (menuDisplayMode === 'always') {
setIsMenuExpanded(true);
setIsMenuHidden(false);
stopCountdown();
} else if (menuDisplayMode === 'auto') {
setIsMenuExpanded(true);
setIsMenuHidden(false);
setIsMenuHidden(false);
if (menuDisplayMode === 'auto') {
startCountdown();
} else {
stopCountdown();
}
}, [menuDisplayMode, startCountdown, stopCountdown]);
@@ -114,13 +107,10 @@ export function useMenuVisibility(): MenuVisibilityState {
}
}, [isMenuMoved]);
const isInvisible = menuDisplayMode === 'off' && !isMenuExpanded;
return {
isMenuExpanded,
isMenuHidden,
isMenuMoved,
isInvisible,
isInitialized,
handleHovered,
handleMoved,

View File

@@ -92,6 +92,34 @@ const en = {
captureTips:
'Capturing system-level keys (such as the Windows key) requires full-screen permission.',
enterFullScreen: 'Toggle full-screen mode.'
},
leaderKey: {
title: 'Leader Key',
desc: 'Bypass browser restrictions and send system shortcuts directly to the remote host.',
howToUse: 'How to Use',
simultaneous: {
title: 'Simultaneous Mode',
desc1: 'Press and hold the Leader Key, then press the shortcut.',
desc2: 'Intuitive, but may conflict with system shortcuts.'
},
sequential: {
title: 'Sequential Mode',
desc1:
'Press the Leader Key → press the shortcut in sequence → press the Leader Key again.',
desc2: 'Requires more steps, but completely avoids system conflicts.'
},
enable: 'Enable Leader Key',
tip: 'When assigned as a Leader Key, this key functions exclusively as a shortcut trigger and loses its default behavior.',
placeholder: 'Please press the Leader Key',
shiftRight: 'Right Shift',
ctrlRight: 'Right Ctrl',
metaRight: 'Right Win',
submit: 'Submit',
recorder: {
rec: 'REC',
activate: 'Activate keys',
input: 'Please press the shortcut...'
}
}
},
mouse: {

View File

@@ -218,10 +218,18 @@ const ko = {
display: '표시',
language: '언어',
languageDesc: '인터페이스 언어를 선택하세요',
menuBar: '메뉴 바',
menuBarDesc: '메뉴 바에 아이콘을 표시',
webTitle: '웹 제목',
webTitleDesc: '웹 페이지 제목 사용자 지정'
webTitleDesc: '웹 페이지 제목 사용자 지정',
menuBar: {
title: '메뉴 바',
mode: '표시 모드',
modeDesc: '메뉴 바를 화면에 표시합니다',
modeOff: '꺼짐',
modeAuto: '자동 숨기기',
modeAlways: '항상 보이기',
icons: '하위 메뉴 아이콘',
iconsDesc: '메뉴 바에 하위 메뉴 아이콘을 표시합니다'
}
},
device: {
title: '장치',

View File

@@ -81,6 +81,33 @@ const zh = {
save: '保存',
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
enterFullScreen: '切换全屏模式。'
},
leaderKey: {
title: '引导键',
desc: '绕过浏览器限制,向远程主机发送被系统拦截的快捷键。',
howToUse: '使用方法',
simultaneous: {
title: '组合按键模式',
desc1: '按住引导键不放,同时按下目标快捷键。',
desc2: '操作直观,但少数快捷键可能因系统占用而无法生效。'
},
sequential: {
title: '序列输入模式',
desc1: '点击【引导键】开始 → 依次点击【快捷键】→ 点击【引导键】结束。',
desc2: '步骤较多,但能完美避开系统键位冲突。'
},
enable: '启用引导键',
tip: '设为引导键后,该按键将仅用于触发快捷键,不再作为普通按键使用。',
placeholder: '请按下引导键',
shiftRight: '右 Shift',
ctrlRight: '右 Ctrl',
metaRight: '右 Win',
submit: '确定',
recorder: {
rec: '录制中',
activate: '激活按键',
input: '请按下快捷键...'
}
}
},
mouse: {

View File

@@ -14,7 +14,7 @@ const zh_tw = {
placeholderPassword2: '請再次輸入密碼',
noEmptyUsername: '使用者名稱不能為空',
noEmptyPassword: '密碼不能為空',
noAccount: '取得使用者資訊失敗,請重新整理網頁或重設密碼',
noAccount: '找不到使用者,請重新整理網頁或重設密碼',
invalidUser: '使用者名稱或密碼錯誤',
error: '非預期性錯誤',
changePassword: '更改密碼',
@@ -33,26 +33,28 @@ const zh_tw = {
reset4: 'SSH 預設帳號:',
change1: '請注意,此操作將同時更新下列密碼:',
change2: '網頁登入密碼',
change3: 'root user密碼 (SSH登入密碼)',
change3: 'root user 密碼 (SSH登入密碼)',
change4: '如果您忘記密碼,需要長按 NanoKVM 上的 BOOT 按鍵來重設密碼。'
}
},
wifi: {
title: 'Wi-Fi',
description: '設定 NanoKVM WiFi',
description: '設定 NanoKVM Wi-Fi',
success: '請檢查 NanoKVM 的網路狀態,並存取新的 IP 位址。',
failed: '操作失敗,請重試。',
invalidMode: '目前模式不支援設定網路。請先前往裝置啟用 Wi-Fi 配置模式。',
confirmBtn: '確定',
finishBtn: '完成'
},
screen: {
scale: '缩放',
title: '螢幕',
video: '編碼格式',
videoDirectTips: '本模式需先啟用HTTPS,請前往「設定 -> 設備」中開啟',
videoDirectTips: '本模式需先啟用 HTTPS,請前往「設定 -> 設備」中開啟',
resolution: '解析度',
auto: '自動',
autoTips:
'在特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。考慮調整遠端主機的解析度或停用自動模式。',
'在某些特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。請調整遠端主機的解析度或停用自動模式。',
fps: '更新頻率',
customizeFps: '自定義',
quality: '品質',
@@ -61,17 +63,26 @@ const zh_tw = {
qualityMedium: '中',
qualityLow: '低',
frameDetect: '影格檢測',
frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止傳輸視訊串流。',
frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止視訊傳輸串流。',
resetHdmi: '重置 HDMI'
},
keyboard: {
title: 'Keyboard',
title: '鍵盤',
paste: '貼上',
tips: '僅支援標準鍵盤字母和符號',
tips: '僅支援標準鍵盤的字母和符號',
placeholder: '請輸入內容',
submit: '送出',
virtual: '虛擬鍵盤',
ctrlaltdel: 'Ctrl+Alt+Del'
ctrlaltdel: 'Ctrl+Alt+Del',
shortcut: {
title: '快捷鍵',
custom: '自定義',
capture: '點選此處錄製快捷鍵',
clear: '清空',
save: '儲存',
captureTips: '錄製系統級按鍵(如 Windows 鍵)需要全螢幕的權限。',
enterFullScreen: '切換全螢幕模式。'
}
},
mouse: {
title: '滑鼠',
@@ -85,53 +96,63 @@ const zh_tw = {
mode: '滑鼠模式',
absolute: '絕對模式',
relative: '相對模式',
direction: '滾輪方向',
scrollUp: '向上',
scrollDown: '向下',
speed: '滾輪速度',
fast: '快',
slow: '慢',
requestPointer: '使用相對模式。請按一下桌面以取得滑鼠游標。',
requestPointer: '正在使用滑鼠相對模式。請按一下桌面以取得滑鼠游標。',
resetHid: '重設 HID',
hidOnly: {
title: 'HID-Only 模式',
desc: "如果您的滑鼠和鍵盤停止回應,且重設 HID 無效,可能是 NanoKVM 與裝置間的相容性問題。請嘗試啟用 HID-Only 模式以獲得更好的相容性。",
tip1: '啟用 HID-Only 模式將會卸載虛擬隨身碟和虛擬網卡',
tip2: '在 HID-Only 模式下,映像掛載功能將被停用',
desc: "如果您的滑鼠和鍵盤沒有反應,且重設 HID 無效,可能是 NanoKVM 與您的裝置間有相容性問題。請嘗試啟用 HID-Only 模式以獲得更好的相容性。",
tip1: '啟用 HID-Only 模式將會停用虛擬隨身碟和虛擬網卡的功能',
tip2: '在 HID-Only 模式下,映像檔掛載功能將被停用',
tip3: 'NanoKVM 將在切換模式後自動重新啟動',
enable: '啟用 HID-Only 模式',
disable: '停用 HID-Only 模式'
}
},
image: {
title: '映像',
title: '映像檔',
loading: '載入中...',
empty: '未找到任何內容',
cdrom: '以CD-ROM模式掛載',
mountMode: '掛載模式',
mountFailed: '掛載失敗',
mountDesc: '在某些系統中,在掛載映像之前需要中斷遠端主機上的虛擬磁碟。',
refresh: 'Refresh the image list',
mountDesc: '在某些系統中,需要在遠端主機中彈出虛擬硬碟後再掛載映像檔。',
unmountFailed: '解除安裝失敗',
unmountDesc: '在某些系統中,需要在遠端主機中手動彈出後再解除安裝映像。',
refresh: '重新整理映像檔列表',
attention: '注意',
deleteConfirm: '確定要刪除該映像檔嗎?',
okBtn: '確定',
cancelBtn: '取消',
tips: {
title: '如何上傳',
usb1: '透過 USB 將 NanoKVM 連接到您的電腦。',
usb2: '確保已安裝虛擬磁碟(設定 - 虛擬磁碟)。',
usb3: '開啟電腦上的虛擬磁碟,將映像檔案複製到虛擬磁碟的根目錄下。',
usb3: '開啟電腦上的虛擬磁碟,將映象檔案複製到虛擬磁碟的根目錄下。',
scp1: '確保 NanoKVM 和您的電腦位於同一區域網路。',
scp2: '開啟電腦上的終端機,使用 SCP 指令將映像檔案上傳到 NanoKVM 的 /data 目錄下。',
scp3: '範例:scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'microSD 卡',
tf1: '此方法適用於 Linux 系統',
tf2: '從 NanoKVM 取得 microSD 卡(FULL 版本請先拆開外殼)。',
tf3: '將 microSD 卡插入讀卡機並連接至電腦。',
tf4: '將映像檔案複製到 microSD 卡的 /data 目錄下。',
tf5: '將 microSD 卡重新插入 NanoKVM。'
tf2: '從 NanoKVM 拔出 microSD 卡(FULL 版本請先拆開外殼)。',
tf3: '使用 USB 讀卡機將 microSD 卡連接至電腦。',
tf4: '將映像檔複製到 microSD 卡的 /data 目錄下。',
tf5: '將 microSD 卡重新插回 NanoKVM。'
}
},
script: {
title: '腳本',
title: '指令碼',
upload: '上傳',
run: '執行',
runBackground: '背景執行',
runFailed: '執行失敗',
attention: '注意',
delDesc: '確定刪除該檔案?',
delDesc: '確定要刪除該檔案嗎?',
confirm: '確定',
cancel: '取消',
delete: '刪除',
@@ -164,22 +185,22 @@ const zh_tw = {
ok: '確定'
},
download: {
title: '下载映像',
input: '請輸入映像檔的下載URL',
title: '下载映像檔',
input: '請輸入映像檔的下載 URL',
ok: '確定',
disabled: '/data 為唯讀目錄,無法下載映像檔'
},
power: {
title: '電源',
showConfirm: 'Confirmation',
showConfirmTip: 'Power operations require an extra confirmation',
title: '電源控制',
showConfirm: '顯示確認框',
showConfirmTip: '電源操作需要二次確認',
reset: '重新啟動',
power: '電源',
powerShort: '電源 (短按)',
powerLong: '電源 (長按)',
resetConfirm: '執行重新啟動按鍵操作?',
powerConfirm: '執行電源按鍵操作?',
okBtn: '是',
resetConfirm: '確認執行重新啟動的按鍵操作嗎?',
powerConfirm: '確認執行電源的按鍵操作嗎?',
okBtn: '確認',
cancelBtn: '取消'
},
settings: {
@@ -191,8 +212,8 @@ const zh_tw = {
mdns: 'mDNS',
application: '應用程式版本',
applicationTip: 'NanoKVM 網頁程式版本',
image: '映像版本',
imageTip: 'NanoKVM 系统镜像版本',
image: '韌體版本',
imageTip: 'NanoKVM 系统韌體版本',
deviceKey: '設備序號',
community: '社群',
hostname: '主機名稱',
@@ -207,10 +228,20 @@ const zh_tw = {
title: '外觀',
display: '顯示',
language: '語言',
menuBar: '選單列',
languageDesc: '選擇介面語言',
menuBarDesc: '是否在選單列中顯示圖案',
webTitle: '網頁標題',
webTitleDesc: '自訂網頁標題'
webTitleDesc: '自訂網頁標題',
menuBar: {
title: '選單列',
mode: '顯示方式',
modeDesc: '選單欄在螢幕上的顯示方式',
modeOff: '關閉',
modeAuto: '自動隱藏',
modeAlways: '始終顯示',
icons: '選單圖示',
iconsDesc: '是否在選單欄中顯示子選單圖示'
}
},
device: {
title: '設備',
@@ -218,57 +249,80 @@ const zh_tw = {
title: 'OLED',
description: '設定 OLED 螢幕自動睡眠時間',
0: '永不',
15: '15秒',
30: '30秒',
60: '1分鐘',
180: '3分鐘',
300: '5分鐘',
600: '10分鐘',
1800: '30分鐘',
3600: '1小時'
15: '15 秒',
30: '30 秒',
60: '1 分鐘',
180: '3 分鐘',
300: '5 分鐘',
600: '10 分鐘',
1800: '30 分鐘',
3600: '1 小時'
},
wifi: {
title: 'Wi-Fi',
description: '設定 Wi-Fi',
setBtn: '設定'
setBtn: '設定',
apMode: 'AP 模式已啟用,該模式下僅支援掃描 QRCode 配網',
connect: '連線 Wi-Fi',
connectDesc1: '請輸入網路名稱和密碼',
connectDesc2: '請輸入密碼以連線此網路',
disconnect: '是否要中斷該網路連線?',
failed: '連線失敗,請重試',
ssid: 'SSID 名稱',
password: '密碼',
joinBtn: '加入',
confirmBtn: '確定',
cancelBtn: '取消'
},
ssh: {
description: '啟用SSH',
description: '啟用 SSH 伺服器',
tip: '啟用前請務必設定強密碼(帳號 - 更改密碼)'
},
tls: {
description: '啟用 HTTPS',
tip: '啟用 HTTPS 可以提高安全性,但可能會增加延遲,特別是使用 MJPEG 編碼格式時。'
description: '啟用 HTTPS 協議',
tip: '啟用 HTTPS 可以提高安全性,但可能會增加傳輸延遲,特別是使用 MJPEG 格式傳輸時。'
},
advanced: '進階設定',
swap: {
title: 'Swap',
disable: '停用',
description: '設定Swap檔大小',
description: '設定 Swap 檔大小',
tip: "啟用此功能可能會減少SD卡的使用壽命!"
},
mouseJiggler: {
title: 'Mouse Jiggler',
title: '滑鼠抖動模式 (Mouse Jiggler)',
description: '避免遠端主機進入休眠狀態',
disable: '停用',
absolute: '絕對模式',
relative: '相對模式'
},
mdns: {
description: '啟用mDNS服務',
description: '啟用 mDNS 發現服務',
tip: "若無需求,建議關閉此功能"
},
hdmi: {
description: '啟用 HDMI/螢幕 輸出'
},
autostart: {
title: '啟動時指令碼設定',
description: '管理能夠在 NanoKVM 啟動時自動執行的相關指令碼',
new: '建立新指令碼',
deleteConfirm: '確定要刪除該檔案嗎?',
yes: '是',
no: '否',
scriptName: 'Script 名稱',
scriptContent: 'Script 內容',
settings: '設定'
},
hidOnly: 'HID-Only 模式',
hidOnlyDesc: '該模式下不再打開虛擬隨身碟,僅保留基礎的 鍵盤/滑鼠 的控制功能。',
disk: '虛擬隨身碟',
diskDesc: '在遠端主機上連接虛擬隨身碟',
network: '虛擬網卡',
networkDesc: '在遠端主機上新增虛擬網卡',
reboot: '重新啟動',
rebootDesc: '你確定要重新啟動 NanoKVM?',
okBtn: '是',
rebootDesc: '您確定要重新啟動 NanoKVM?',
okBtn: '確定',
cancelBtn: '取消'
},
tailscale: {
@@ -278,6 +332,10 @@ const zh_tw = {
tip: '當記憶體使用量超過限制時,會更積極的進行垃圾回收來嘗試釋放記憶體。若使用 Tailscale 建議設定為 50MB,於重啟 Tailscale 後生效。',
disable: '關閉'
},
swap: {
title: 'Swap',
tip: '如果啟用記憶體最佳化後依然存在問題,可以嘗試開啟 Swap。啟用後會將交換檔案設定為 256MB,可以在「設定 - 裝置」中修改該選項。'
},
restart: '確定要重啟 Tailscale 嗎?',
stop: '確定要停止 Tailscale 嗎?',
stopDesc: '此操作將會登出帳號,並停止開機自動啟動。',
@@ -290,9 +348,11 @@ const zh_tw = {
download: '下載',
package: '安裝包',
unzip: '並解壓縮它',
upTailscale: '將 Tailscale 上傳到 NanoKVM 目錄 /usr/bin/',
upTailscaled: '將 Tailscale 上傳到 NanoKVM 目錄 /usr/sbin/',
upTailscale: '將 Tailscale 上傳到 NanoKVM 的 /usr/bin/ 資料夾',
upTailscaled: '將 Tailscale 上傳到 NanoKVM 的 /usr/sbin/ 資料夾',
refresh: '重新整理頁面',
notRunning: 'Tailscale 尚未執行',
run: '啟動',
notLogin: '設備尚未綁定。請登入並將該裝置綁定到您的帳戶。',
urlPeriod: '此網址有效期限為 10 分鐘',
login: '登入',
@@ -302,24 +362,33 @@ const zh_tw = {
deviceIP: '裝置 IP',
account: '帳號',
logout: '登出',
logoutDesc: '確認登出?',
logoutDesc: '確認要登出嗎?',
uninstall: '移除 Tailscale',
uninstallDesc: '確定要解除安裝 Tailscale 嗎?',
reboot: '重啟',
rebootDesc: '您確定要重啟 NanoKVM 嗎?',
okBtn: '確認',
cancelBtn: '取消'
},
update: {
title: '檢查更新',
queryFailed: '取得版本失敗',
queryFailed: '取得版本號失敗',
updateFailed: '更新失敗。請重試。',
isLatest: '您已經擁有最新版本。',
available: '有可用更新。確定要更新嗎?',
updating: '正在更新。請稍等...',
isLatest: '您已經是最新版本。',
available: '發現有可用更新。您確定要更新嗎?',
updating: '更新中,請稍候...',
confirm: '確定',
cancel: '取消',
preview: '預覽更新',
previewDesc: '搶先體驗新功能和改進',
previewTip:
'請注意,預覽版本可能包含錯誤或功能不完整!'
previewDesc: '預覽版本,搶先體驗新功能和改進',
previewTip: '請注意,預覽版本可能包含一些不穩定因素或未完善的功能!',
offline: {
title: '離線更新',
desc: '透過本地安裝包進行更新',
upload: '上傳',
invalidName: '檔名格式錯誤,請前往 GitHub 釋出頁下載安裝包。',
updateFailed: '更新失敗,請重試'
}
},
account: {
title: '帳號',
@@ -327,9 +396,9 @@ const zh_tw = {
password: '密碼',
updateBtn: '修改',
logoutBtn: '登出',
logoutDesc: '你確定要登出嗎?',
okBtn: '是',
cancelBtn: '取消'
logoutDesc: '您確定要登出嗎?',
confirm: '確定',
cancel: '取消'
}
},
error: {

View File

@@ -5,3 +5,6 @@ export const isKeyboardEnableAtom = atom(true);
// is the virtual keyboard opened
export const isKeyboardOpenAtom = atom(false);
// leader key code for bypassing browser shortcuts (empty string means disabled)
export const leaderKeyAtom = atom('');

View File

@@ -1,28 +1,33 @@
export type System = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
export interface BrowserInfo {
os: string;
os: System;
isChrome: boolean;
}
export function getOperatingSystem(): string {
export function getOperatingSystem(): System {
if (typeof window === 'undefined') {
return 'Unknown';
}
const platformSource =
if ('userAgentData' in navigator) {
// @ts-expect-error check userAgentData.platform
'userAgentData' in navigator ? navigator.userAgentData.platform : navigator.platform;
if (!platformSource) {
return 'Unknown';
const platform = navigator.userAgentData?.platform?.toLowerCase();
if (platform) {
if (platform === 'windows') return 'Windows';
if (platform === 'macos') return 'macOS';
if (platform === 'linux' || platform === 'android') return 'Linux';
}
}
const platform = platformSource.toLowerCase();
// Fallback to User Agent
const userAgent = navigator.userAgent;
if (platform.startsWith('win')) return 'Windows';
if (platform.startsWith('mac')) return 'macOS';
if (platform.startsWith('linux')) return 'Linux';
if (/Win/i.test(userAgent)) return 'Windows';
if (/Mac|iPhone|iPod|iPad/i.test(userAgent)) return 'macOS';
if (/Linux|Android/i.test(userAgent)) return 'Linux';
return platformSource;
return 'Unknown';
}
export function isChromeBrowser(): boolean {

View File

@@ -7,31 +7,38 @@ import { isModifier } from '@/lib/keymap';
import { client, MessageEvent } from '@/lib/websocket.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
interface AltGrState {
active: boolean;
ctrlLeftTimestamp: number;
}
const ALTGR_THRESHOLD_MS = 10;
import { Recorder } from './recorder.tsx';
import { useAltGr } from './useAltGr.ts';
import { useLeaderKey } from './useLeaderKey.ts';
import { normalizeKeyCode } from './utils.ts';
export const Keyboard = () => {
const os = getOperatingSystem();
const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom);
const keyboardRef = useRef(new KeyboardReport());
const pressedKeys = useRef(new Set<string>());
const altGrState = useRef<AltGrState | null>(null);
const isComposing = useRef(false);
// Send key event helper
const sendKeyEvent = (type: 'keydown' | 'keyup', code: string) => {
const kb = keyboardRef.current;
const report = type === 'keydown' ? kb.keyDown(code) : kb.keyUp(code);
sendReport(report);
};
// Init leader key handler
const leaderKey = useLeaderKey(pressedKeys, sendKeyEvent);
const leaderKeyRef = useRef(leaderKey);
leaderKeyRef.current = leaderKey;
// Init AltGr key handler
const altGr = useAltGr(os, pressedKeys, sendKeyEvent);
const altGrRef = useRef(altGr);
altGrRef.current = altGr;
useEffect(() => {
if (getOperatingSystem() === 'Windows' && !altGrState.current) {
altGrState.current = { active: false, ctrlLeftTimestamp: 0 };
}
if (!isKeyboardEnabled) {
releaseKeys();
return;
}
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
document.addEventListener('compositionstart', handleCompositionStart);
@@ -39,111 +46,94 @@ export const Keyboard = () => {
window.addEventListener('blur', handleBlur);
document.addEventListener('visibilitychange', handleVisibilityChange);
// Key down event
function handleKeyDown(event: KeyboardEvent) {
if (!isKeyboardEnabled) return;
// Skip during IME composition
if (isComposing.current || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
const code = event.code;
if (pressedKeys.current.has(code)) return;
const code = normalizeKeyCode(event, os);
if (!code || pressedKeys.current.has(code)) return;
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
if (altGrState.current) {
if (code === 'ControlLeft') {
altGrState.current.ctrlLeftTimestamp = event.timeStamp;
} else if (code === 'AltRight') {
const timeDiff = event.timeStamp - altGrState.current.ctrlLeftTimestamp;
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
pressedKeys.current.delete('ControlLeft');
handleKeyEvent({ type: 'keyup', code: 'ControlLeft' });
altGrState.current.active = true;
}
}
// Handle leader key
const leaderHandled = leaderKeyRef.current.handleKeyDown(code);
if (leaderHandled) {
return;
}
// Handle AltGr key
altGrRef.current.handleKeyDown(code, event.timeStamp);
pressedKeys.current.add(code);
handleKeyEvent({ type: 'keydown', code });
sendKeyEvent('keydown', code);
}
// Key up event
function handleKeyUp(event: KeyboardEvent) {
if (!isKeyboardEnabled) return;
if (isComposing.current || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
const code = event.code;
const code = normalizeKeyCode(event, os);
if (!code) return;
// Handle AltGr state for Windows
if (altGrState.current?.active) {
if (code === 'ControlLeft') return;
if (code === 'AltRight') {
altGrState.current.active = false;
}
// Handle leader key release
const leaderHandled = leaderKeyRef.current.handleKeyUp(code);
if (leaderHandled) {
return;
}
// Compatible with macOS's command key combinations
if (code === 'MetaLeft' || code === 'MetaRight') {
const keysToRelease: string[] = [];
pressedKeys.current.forEach((pressedCode) => {
if (!isModifier(pressedCode)) {
keysToRelease.push(pressedCode);
}
});
// Handle AltGr key release
const altGrHandled = altGrRef.current.handleKeyUp(code);
if (altGrHandled) {
return;
}
for (const key of keysToRelease) {
handleKeyEvent({ type: 'keyup', code: key });
pressedKeys.current.delete(key);
}
// Release Meta combinations (macOS compatibility)
if (code === 'MetaLeft' || code === 'MetaRight') {
releaseMetaCombinations();
}
pressedKeys.current.delete(code);
handleKeyEvent({ type: 'keyup', code });
sendKeyEvent('keyup', code);
}
// Composition start event
function handleCompositionStart() {
isComposing.current = true;
}
// Composition end event
function handleCompositionEnd() {
isComposing.current = false;
}
// Release all keys when window loses focus
function handleBlur() {
releaseKeys();
}
// Release all keys before window closes
function handleVisibilityChange() {
if (document.hidden) {
releaseKeys();
}
}
// Release all keys
function releaseMetaCombinations() {
const keysToRelease = Array.from(pressedKeys.current).filter((code) => !isModifier(code));
for (const key of keysToRelease) {
sendKeyEvent('keyup', key);
pressedKeys.current.delete(key);
}
}
function releaseKeys() {
pressedKeys.current.forEach((code) => {
handleKeyEvent({ type: 'keyup', code });
sendKeyEvent('keyup', code);
});
pressedKeys.current.clear();
// Reset AltGr state
if (altGrState.current) {
altGrState.current.active = false;
altGrState.current.ctrlLeftTimestamp = 0;
}
leaderKeyRef.current.reset();
altGrRef.current.reset();
const report = keyboardRef.current.reset();
sendReport(report);
@@ -161,18 +151,14 @@ export const Keyboard = () => {
};
}, [isKeyboardEnabled]);
// Keyboard handler
function handleKeyEvent(event: { type: 'keydown' | 'keyup'; code: string }) {
const kb = keyboardRef.current;
const report = event.type === 'keydown' ? kb.keyDown(event.code) : kb.keyUp(event.code);
sendReport(report);
}
// Send keyboard report
function sendReport(report: Uint8Array) {
const data = new Uint8Array([MessageEvent.Keyboard, ...report]);
client.send(data);
}
return <></>;
return (
<>
<Recorder recordMode={leaderKey.recordMode} recordedKeys={leaderKey.recordedKeys} />
</>
);
};

View File

@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next';
interface RecorderProps {
recordMode: boolean;
recordedKeys: string[];
}
export const Recorder = ({ recordMode, recordedKeys }: RecorderProps) => {
const { t } = useTranslation();
if (!recordMode) {
return <></>;
}
return (
<div className="animate-in fade-in slide-in-from-top-4 pointer-events-none fixed right-5 top-5 z-[9999] flex min-w-[150px] flex-col gap-2 rounded-xl border border-white/10 bg-black/60 p-4 shadow-2xl backdrop-blur-md transition-all duration-200">
{recordedKeys.length === 0 ? (
<>
<div className="flex items-center gap-2">
<div className="relative flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex size-2.5 rounded-full bg-amber-500"></span>
</div>
<span className="text-xs font-semibold tracking-wider text-white/90">
{t('keyboard.leaderKey.recorder.rec')}
</span>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<span className="text-sm italic text-white/50">
{t('keyboard.leaderKey.recorder.input')}
</span>
</div>
</>
) : (
<>
<div className="flex items-center gap-2">
<div className="relative flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex size-2.5 rounded-full bg-green-500"></span>
</div>
<span className="text-xs text-white/90">
{t('keyboard.leaderKey.recorder.activate')}
</span>
</div>
<div className="flex flex-wrap gap-2 pt-1">
{recordedKeys.map((key) => (
<span
key={key}
className="flex items-center justify-center rounded border-b-2 border-white/20 bg-white/10 px-2 py-0.5 font-mono text-sm font-medium text-white shadow-sm"
>
{key}
</span>
))}
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,77 @@
import React, { useRef } from 'react';
const ALTGR_THRESHOLD_MS = 10;
interface AltGrState {
active: boolean;
ctrlLeftTimestamp: number;
}
export interface AltGrHandlers {
handleKeyDown: (code: string, timestamp: number) => void;
handleKeyUp: (code: string) => boolean;
reset: () => void;
isActive: () => boolean;
}
export function useAltGr(
os: string,
pressedKeys: React.MutableRefObject<Set<string>>,
sendKeyEvent: (type: 'keydown' | 'keyup', code: string) => void
): AltGrHandlers {
const state = useRef<AltGrState | null>(null);
if (os === 'Windows' && !state.current) {
state.current = { active: false, ctrlLeftTimestamp: 0 };
}
const handleKeyDown = (code: string, timestamp: number): void => {
if (!state.current) return;
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
if (code === 'ControlLeft') {
state.current.ctrlLeftTimestamp = timestamp;
return;
}
if (code === 'AltRight') {
const timeDiff = timestamp - state.current.ctrlLeftTimestamp;
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
pressedKeys.current.delete('ControlLeft');
sendKeyEvent('keyup', 'ControlLeft');
state.current.active = true;
return;
}
}
};
const handleKeyUp = (code: string): boolean => {
if (!state.current?.active) {
return false;
}
if (code === 'ControlLeft') {
pressedKeys.current.delete('ControlLeft');
return true;
}
if (code === 'AltRight') {
pressedKeys.current.delete('ControlLeft');
state.current.active = false;
return false;
}
return false;
};
const reset = () => {
if (state.current) {
state.current.active = false;
state.current.ctrlLeftTimestamp = 0;
}
};
const isActive = () => state.current?.active ?? false;
return { handleKeyDown, handleKeyUp, reset, isActive };
}

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import * as api from '@/api/hid.ts';
import { isModifier } from '@/lib/keymap';
import { leaderKeyAtom } from '@/jotai/keyboard.ts';
interface LeaderKeyState {
code: string;
startTime: number;
recordMode: boolean;
recordModifiers: string[];
}
interface LeaderKeyHandlers {
handleKeyDown: (code: string) => boolean;
handleKeyUp: (code: string) => boolean;
reset: () => void;
recordMode: boolean;
recordedKeys: string[];
}
const RECORD_MODE_THRESHOLD_MS = 350;
export function useLeaderKey(
pressedKeys: React.MutableRefObject<Set<string>>,
sendKeyEvent: (type: 'keydown' | 'keyup', code: string) => void
): LeaderKeyHandlers {
const [leaderKeyCode, setLeaderKeyCode] = useAtom(leaderKeyAtom);
const [recordMode, setRecordMode] = useState(false);
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
const leaderState = useRef<LeaderKeyState>({
code: '',
startTime: 0,
recordMode: false,
recordModifiers: []
});
// Get leader key
useEffect(() => {
api.getLeaderKey().then((rsp: any) => {
if (rsp.code === 0 && rsp.data?.key !== undefined) {
setLeaderKeyCode(rsp.data.key);
}
});
}, [setLeaderKeyCode]);
// Update leader key
useEffect(() => {
leaderState.current.code = leaderKeyCode;
}, [leaderKeyCode]);
const handleKeyDown = (code: string): boolean => {
const leader = leaderState.current;
// Leader key pressed
if (leader.code && code === leader.code) {
if (!leader.recordMode) {
// Enter record mode
leader.startTime = Date.now();
leader.recordMode = true;
setRecordMode(true);
}
pressedKeys.current.add(code);
return true;
}
// Record mode
if (leader.recordMode) {
if (isModifier(code)) {
// Modifier: send keydown immediately and track it
if (!leader.recordModifiers.includes(code)) {
leader.recordModifiers.push(code);
setRecordedKeys([...leaderState.current.recordModifiers]);
sendKeyEvent('keydown', code);
}
} else {
// Non-modifier: send keydown + keyup immediately
sendKeyEvent('keydown', code);
sendKeyEvent('keyup', code);
}
pressedKeys.current.add(code);
return true;
}
return false;
};
const handleKeyUp = (code: string): boolean => {
const leader = leaderState.current;
// Leader key released
if (leader.code && code === leader.code) {
if (leader.recordMode && Date.now() - leader.startTime > RECORD_MODE_THRESHOLD_MS) {
// Exit record mode
reset();
}
pressedKeys.current.delete(code);
return true;
}
// In record mode, handle keyup
if (leader.recordMode && code !== leader.code) {
pressedKeys.current.delete(code);
return true;
}
return false;
};
const reset = () => {
const leader = leaderState.current;
for (const key of leader.recordModifiers) {
sendKeyEvent('keyup', key);
}
leader.startTime = 0;
leader.recordMode = false;
leader.recordModifiers = [];
setRecordMode(false);
setRecordedKeys([]);
};
return { handleKeyDown, handleKeyUp, reset, recordMode, recordedKeys };
}

View File

@@ -0,0 +1,26 @@
export function normalizeKeyCode(event: KeyboardEvent, os?: string): string {
if (event.code) {
return event.code;
}
// Fallback: use event.key + event.location to determine the key
// event.location: 1 = left, 2 = right, 0 = standard (non-positional)
if (event.key === 'Shift') {
if (event.location === 0 && os === 'Windows') {
return 'ShiftRight';
}
return event.location === 2 ? 'ShiftRight' : 'ShiftLeft';
}
if (event.key === 'Control') {
return event.location === 2 ? 'ControlRight' : 'ControlLeft';
}
if (event.key === 'Alt') {
return event.location === 2 ? 'AltRight' : 'AltLeft';
}
if (event.key === 'Meta') {
return event.location === 2 ? 'MetaRight' : 'MetaLeft';
}
return event.code;
}

View File

@@ -5,7 +5,7 @@ import { useAtomValue } from 'jotai';
import { GripVerticalIcon } from 'lucide-react';
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
import { menuDisabledItemsAtom, menuDisplayModeAtom } from '@/jotai/settings.ts';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
import { useMenuBounds } from '@/hooks/useMenuBounds.ts';
import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts';
@@ -25,14 +25,12 @@ import { Wol } from './wol';
export const Menu = () => {
const nodeRef = useRef<HTMLDivElement | null>(null);
const menuDisplayMode = useAtomValue(menuDisplayModeAtom);
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
const {
isInitialized,
isMenuExpanded,
isMenuHidden,
isInvisible,
handleHovered,
handleMoved,
setIsMenuExpanded
@@ -61,8 +59,7 @@ export const Menu = () => {
ref={nodeRef}
className={clsx(
'fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2 transition-opacity duration-300',
isInitialized ? 'opacity-100' : 'opacity-0',
isInvisible && 'invisible'
isInitialized ? 'opacity-100' : 'opacity-0'
)}
onMouseEnter={() => handleHovered(true)}
onMouseLeave={() => handleHovered(false)}
@@ -118,7 +115,7 @@ export const Menu = () => {
</div>
{/* Menubar expand button */}
{!isMenuExpanded && menuDisplayMode !== 'off' && <Expand toggleMenu={setIsMenuExpanded} />}
{!isMenuExpanded && <Expand toggleMenu={setIsMenuExpanded} />}
</div>
</Draggable>
);

View File

@@ -1,8 +1,10 @@
import { Divider } from 'antd';
import { KeyboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { MenuItem } from '@/components/menu-item.tsx';
import { LeaderKey } from './leader-key.tsx';
import { Paste } from './paste.tsx';
import { Shortcuts } from './shortcuts';
import { VirtualKeyboard } from './virtual-keyboard.tsx';
@@ -10,17 +12,19 @@ import { VirtualKeyboard } from './virtual-keyboard.tsx';
export const Keyboard = () => {
const { t } = useTranslation();
const content = (
<div className="flex flex-col space-y-1">
<Paste />
<VirtualKeyboard />
<Shortcuts />
<Divider style={{ margin: '5px 0', opacity: 0.5 }} />
<LeaderKey />
</div>
);
return (
<MenuItem
title={t('keyboard.title')}
icon={<KeyboardIcon size={18} />}
content={
<div className="flex flex-col space-y-1">
<Paste />
<VirtualKeyboard />
<Shortcuts />
</div>
}
/>
<MenuItem title={t('keyboard.title')} icon={<KeyboardIcon size={18} />} content={content} />
);
};

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import { Button, Input, Modal, Switch } from 'antd';
import clsx from 'clsx';
import { useAtom, useSetAtom } from 'jotai';
import {
ArrowBigUpIcon,
ChevronRightIcon,
ChevronUpIcon,
CommandIcon,
KeyboardIcon,
KeyIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/hid.ts';
import { isKeyboardEnableAtom, leaderKeyAtom } from '@/jotai/keyboard.ts';
export const LeaderKey = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [leaderKey, setLeaderKey] = useAtom(leaderKeyAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLeaderKeyEnable, setIsLeaderKeyEnable] = useState(false);
const [tempLeaderKey, setTempLeaderKey] = useState('');
const [isFocused, setIsFocused] = useState(false);
const [isDocCollapsed, setIsDocCollapsed] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const optionalKeys = [
{
value: 'ShiftRight',
label: t('keyboard.leaderKey.shiftRight'),
icon: <ArrowBigUpIcon size={16} />
},
{
value: 'ControlRight',
label: t('keyboard.leaderKey.ctrlRight'),
icon: <ChevronUpIcon size={16} />
},
{
value: 'MetaRight',
label: t('keyboard.leaderKey.metaRight'),
icon: <CommandIcon size={16} />
}
];
useEffect(() => {
if (!isFocused) return;
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
setTempLeaderKey(event.code);
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isFocused]);
function openModal() {
setIsKeyboardEnable(false);
setIsLeaderKeyEnable(!!leaderKey);
setTempLeaderKey(leaderKey);
setIsDocCollapsed(true);
setIsModalOpen(true);
}
function closeModal() {
setIsKeyboardEnable(true);
setIsModalOpen(false);
}
function submit() {
if (isLoading) return;
const key = isLeaderKeyEnable ? tempLeaderKey : '';
if (key === leaderKey) {
closeModal();
return;
}
setIsLoading(true);
api
.setLeaderKey(key)
.then((rsp) => {
if (rsp.code === 0) {
setLeaderKey(key);
closeModal();
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<>
<div
className="flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70"
onClick={openModal}
>
<KeyIcon size={18} />
<span>{t('keyboard.leaderKey.title')}</span>
</div>
<Modal width={510} keyboard={false} footer={null} open={isModalOpen} onCancel={closeModal}>
<div className="my-3 flex flex-col space-y-3">
{/* title */}
<div className="flex flex-col space-y-1">
<span className="text-lg"> {t('keyboard.leaderKey.title')}</span>
<span className="text-xs text-neutral-500">{t('keyboard.leaderKey.desc')}</span>
</div>
{/* Document */}
<div className="rounded-lg bg-neutral-800 py-3">
<div
className="flex cursor-pointer items-center justify-between px-5"
onClick={() => setIsDocCollapsed((c) => !c)}
>
<span>{t('keyboard.leaderKey.howToUse')}</span>
<div
className={clsx(
'transition-300 size-[18px] text-neutral-500 transition',
isDocCollapsed ? 'rotate-0' : 'rotate-90'
)}
>
<ChevronRightIcon size={18} />
</div>
</div>
{!isDocCollapsed && (
<ul className="list-outside space-y-3">
{['simultaneous', 'sequential'].map((key) => (
<li>
<div className="flex flex-col space-y-2 pr-1">
<span className="">{t(`keyboard.leaderKey.${key}.title`)}</span>
<span className="text-sm text-neutral-400">
{t(`keyboard.leaderKey.${key}.desc1`)}
</span>
<span className="text-sm text-neutral-400">
{t(`keyboard.leaderKey.${key}.desc2`)}
</span>
</div>
</li>
))}
</ul>
)}
</div>
{/* Switch */}
<div className="flex items-center justify-between rounded-lg bg-neutral-800 px-5 py-3">
<span className="select-none">{t('keyboard.leaderKey.enable')}</span>
<Switch value={isLeaderKeyEnable} onChange={setIsLeaderKeyEnable} />
</div>
{/* Keyboard recorder */}
{isLeaderKeyEnable && (
<div className="flex flex-col space-y-5 rounded-lg bg-neutral-800 px-5 py-5">
<span className="text-sm text-neutral-400">{t('keyboard.leaderKey.tip')}</span>
<Input
value={tempLeaderKey}
className="flex-1"
placeholder={t('keyboard.leaderKey.placeholder')}
prefix={<KeyboardIcon size={16} className="text-neutral-500" />}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<div className="flex items-center justify-center space-x-3">
{optionalKeys.map((key) => (
<div
key={key.value}
className={clsx(
'flex w-28 cursor-pointer items-center justify-center space-x-1 rounded py-1 shadow-lg shadow-neutral-800 outline',
key.value === tempLeaderKey
? 'text-neutral-300 outline-blue-500'
: 'text-neutral-500 outline-neutral-700 hover:shadow-xl hover:outline-neutral-600'
)}
onClick={() => setTempLeaderKey((k) => (k === key.value ? '' : key.value))}
>
{key.icon}
<span>{key.label}</span>
</div>
))}
</div>
</div>
)}
<div className="flex w-full justify-center pt-5">
<Button className="w-24" type="primary" loading={isLoading} onClick={submit}>
{t('keyboard.leaderKey.submit')}
</Button>
</div>
</div>
</Modal>
</>
);
};

View File

@@ -13,8 +13,6 @@ type UpdateProps = {
setIsLocked: (isClosable: boolean) => void;
};
// type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
export const Update = ({ setIsLocked }: UpdateProps) => {
const { t } = useTranslation();
@@ -85,7 +83,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
<div className="text-base">{t('settings.update.title')}</div>
<Divider className="opacity-50" />
<Preview />
<Preview checkForUpdates={checkForUpdates} />
<Offline
status={status}
setStatus={setStatus}

View File

@@ -5,7 +5,11 @@ import { useTranslation } from 'react-i18next';
import * as api from '@/api/application.ts';
export const Preview = () => {
interface PreviewProps {
checkForUpdates: () => void;
}
export const Preview = ({ checkForUpdates }: PreviewProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
@@ -48,6 +52,7 @@ export const Preview = () => {
}
setIsEnabled(enable);
checkForUpdates();
})
.finally(() => {
setIsLoading(false);