diff --git a/package-lock.json b/package-lock.json index 44db4c5b..67eeab74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "tailwind-merge": "^3.3.1", "validator": "^13.15.15", "ws": "^8.18.3", + "xterm": "^5.3.0", "zod": "^4.0.5" }, "devDependencies": { @@ -10830,6 +10831,13 @@ "node": ">=0.4" } }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT" + }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", diff --git a/package.json b/package.json index f25526da..74f44274 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "tailwind-merge": "^3.3.1", "validator": "^13.15.15", "ws": "^8.18.3", + "xterm": "^5.3.0", "zod": "^4.0.5" }, "devDependencies": { diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index d1d55c02..0003cbc5 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -208,7 +208,7 @@ "downloadSample": "Download Sample", "formatGuide": "Format Guide", "uncategorized": "Uncategorized", - "confirmDelete": "Are you sure you want to delete \"{{name}}\"?", + "confirmDelete": "Are you sure you want to delete \"{{name}}\" ?", "failedToDeleteHost": "Failed to delete host", "jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts", "noHostsInJson": "No hosts found in JSON file", @@ -810,5 +810,9 @@ "invalidVerificationCode": "Invalid verification code", "failedToDisableTotp": "Failed to disable TOTP", "failedToGenerateBackupCodes": "Failed to generate backup codes" + }, + "mobile": { + "selectHostToStart": "Select a host to start your terminal session", + "limitedSupportMessage": "Mobile support is currently limited. A dedicated mobile app is coming soon to enhance your experience." } } \ No newline at end of file diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index ed52f6fa..d5d6edab 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -614,7 +614,7 @@ "firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志,或创建", "external": "外部", "loginWithExternal": "使用外部提供商登录", - "loginWithExternalDesc": "使用您配置的外部身份提供商登录", + "loginWithExternalDesc": "使用您配置的外部身份提供者登录", "resetPasswordButton": "重置密码", "sendResetCode": "发送重置代码", "resetCodeDesc": "输入您的用户名以接收密码重置代码。代码将记录在 docker 容器日志中。", @@ -852,5 +852,9 @@ "invalidVerificationCode": "无效的验证码", "failedToDisableTotp": "禁用 TOTP 失败", "failedToGenerateBackupCodes": "生成备用码失败" + }, + "mobile": { + "selectHostToStart": "选择一个主机以开始您的终端会话", + "limitedSupportMessage": "移动端支持目前有限。我们即将推出专门的移动应用以提升您的体验。" } } \ No newline at end of file diff --git a/src/index.css b/src/index.css index 89185ec7..60f4db93 100644 --- a/src/index.css +++ b/src/index.css @@ -123,6 +123,10 @@ } @layer base { + html, body { + height: 100%; + } + * { @apply border-border outline-ring/50; } diff --git a/src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx b/src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx deleted file mode 100644 index 82c9b7aa..00000000 --- a/src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import {Button} from "@/components/ui/button"; -import {Menu, X, Terminal as TerminalIcon} from "lucide-react"; -import {useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx"; -import {cn} from "@/lib/utils.ts"; - -interface MenuProps { - onSidebarOpenClick?: () => void; -} - -export function BottomNavbar({onSidebarOpenClick}: MenuProps) { - const {tabs, currentTab, setCurrentTab, removeTab} = useTabs(); - - return ( -
- -
-
- {tabs.map(tab => ( -
- - -
- ))} -
-
-
- ) -} diff --git a/src/ui/Mobile/Apps/Terminal/Terminal.tsx b/src/ui/Mobile/Apps/Terminal/Terminal.tsx index af0c9c80..82944e52 100644 --- a/src/ui/Mobile/Apps/Terminal/Terminal.tsx +++ b/src/ui/Mobile/Apps/Terminal/Terminal.tsx @@ -97,17 +97,6 @@ export const Terminal = forwardRef(function SSHTerminal( return () => window.removeEventListener('resize', handleWindowResize); }, []); - useEffect(() => { - if (!terminal) return; - - const textarea = (terminal as any)._core?._textarea as HTMLTextAreaElement | undefined; - if (textarea) { - textarea.setAttribute("readonly", "true"); - textarea.setAttribute("inputmode", "none"); - textarea.style.caretColor = "transparent"; - } - }, [terminal]); - function handleWindowResize() { if (!isVisibleRef.current) return; fitAddonRef.current?.fit(); @@ -158,7 +147,7 @@ export const Terminal = forwardRef(function SSHTerminal( if (!terminal || !xtermRef.current || !hostConfig) return; terminal.options = { - cursorBlink: true, + cursorBlink: false, cursorStyle: 'bar', scrollback: 10000, fontSize: 14, @@ -173,6 +162,8 @@ export const Terminal = forwardRef(function SSHTerminal( fastScrollModifier: 'alt', fastScrollSensitivity: 5, allowProposedApi: true, + disableStdin: true, + cursorInactiveStyle: "bar", }; const fitAddon = new FitAddon(); @@ -187,6 +178,14 @@ export const Terminal = forwardRef(function SSHTerminal( terminal.loadAddon(webLinksAddon); terminal.open(xtermRef.current); + const textarea = xtermRef.current.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null; + if (textarea) { + textarea.readOnly = true; + textarea.blur(); + } + + terminal.focus = () => {}; + const resizeObserver = new ResizeObserver(() => { if (resizeTimeout.current) clearTimeout(resizeTimeout.current); resizeTimeout.current = setTimeout(() => { diff --git a/src/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx b/src/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx index 68c40217..d93465ef 100644 --- a/src/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx +++ b/src/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx @@ -1,18 +1,26 @@ -import React, {useState} from "react"; +import React, {useState, useCallback, useEffect} from "react"; import Keyboard from "react-simple-keyboard"; import "react-simple-keyboard/build/css/index.css"; import "./kb-dark-theme.css"; interface TerminalKeyboardProps { onSendInput: (input: string) => void; + onLayoutChange: () => void; } -export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) { +export function TerminalKeyboard({onSendInput, onLayoutChange}: TerminalKeyboardProps) { const [layoutName, setLayoutName] = useState("default"); const [isCtrl, setIsCtrl] = useState(false); const [isAlt, setIsAlt] = useState(false); - const onKeyPress = async (button: string) => { + useEffect(() => { + if (onLayoutChange) { + const timeoutId = setTimeout(() => onLayoutChange(), 100); + return () => clearTimeout(timeoutId); + } + }, [layoutName, onLayoutChange]); + + const onKeyPress = useCallback((button: string) => { if (button === "{shift}") { setLayoutName("shift"); return; @@ -47,20 +55,6 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) { return; } - if (button === "{paste}") { - if (navigator.clipboard?.readText) { - try { - const text = await navigator.clipboard.readText(); - if (text) { - onSendInput(text); - } - } catch (err) { - - } - } - return; - } - let input = button; const specialKeyMap: { [key: string]: string } = { @@ -90,8 +84,16 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) { input = `\x1b${input}`; } + try { + if (navigator.vibrate) { + navigator.vibrate(20); + } + } catch (e) { + console.error("Vibration failed:", e); + } + onSendInput(input); - }; + }, [onSendInput, isCtrl, isAlt]); const buttonTheme = [ { @@ -104,7 +106,7 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) { }, { class: "hg-space-small", - buttons: "{hide} {less} {more}", + buttons: "{hide} {unhide} {less} {more}", } ]; @@ -116,7 +118,7 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) { } return ( -
+
", "F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", - "{arrowLeft} {arrowRight} {arrowUp} {arrowDown} {paste} {backspace}", + "{arrowLeft} {arrowRight} {arrowUp} {arrowDown} {backspace}", "{hide} {less} {space} {enter}", ], hide: [ @@ -166,7 +168,6 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) { "{tab}": "tab", "{ctrl}": "ctrl", "{alt}": "alt", - "{paste}": "paste", "{end}": "end", "{home}": "home", "{pgUp}": "pgUp", @@ -174,8 +175,9 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) { }} theme={"hg-theme-default dark-theme"} useTouchEvents={true} + disableButtonHold={true} buttonTheme={buttonTheme} />
); -} +} \ No newline at end of file diff --git a/src/ui/Mobile/Apps/Terminal/kb-dark-theme.css b/src/ui/Mobile/Apps/Terminal/kb-dark-theme.css index 9e8a4908..213d48ca 100644 --- a/src/ui/Mobile/Apps/Terminal/kb-dark-theme.css +++ b/src/ui/Mobile/Apps/Terminal/kb-dark-theme.css @@ -32,7 +32,7 @@ } .hg-space-medium { - width: 60px; + width: 70px; } .hg-space-small { diff --git a/src/ui/Mobile/MobileApp.tsx b/src/ui/Mobile/MobileApp.tsx index 0074f4e4..9e417c5a 100644 --- a/src/ui/Mobile/MobileApp.tsx +++ b/src/ui/Mobile/MobileApp.tsx @@ -1,11 +1,12 @@ import React, {useRef, FC, useState, useEffect} from "react"; import {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx"; import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx"; -import {BottomNavbar} from "@/ui/Mobile/Apps/Navigation/BottomNavbar.tsx"; -import {LeftSidebar} from "@/ui/Mobile/Apps/Navigation/LeftSidebar.tsx"; -import {TabProvider, useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx"; +import {BottomNavbar} from "@/ui/Mobile/Navigation/BottomNavbar.tsx"; +import {LeftSidebar} from "@/ui/Mobile/Navigation/LeftSidebar.tsx"; +import {TabProvider, useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx"; import {getUserInfo} from "@/ui/main-axios.ts"; import {HomepageAuth} from "@/ui/Mobile/Homepage/HomepageAuth.tsx"; +import {useTranslation} from "react-i18next"; function getCookie(name: string) { return document.cookie.split('; ').reduce((r, v) => { @@ -15,6 +16,7 @@ function getCookie(name: string) { } const AppContent: FC = () => { + const {t} = useTranslation(); const {tabs, currentTab, getTab} = useTabs(); const [isSidebarOpen, setIsSidebarOpen] = React.useState(true); const [ready, setReady] = React.useState(true); @@ -58,6 +60,14 @@ const AppContent: FC = () => { return () => window.removeEventListener('storage', handleStorageChange) }, []) + useEffect(() => { + const interval = setInterval(() => { + fitCurrentTerminal() + }, 2000); + + return () => clearInterval(interval); + }, []); + const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => { setIsAuthenticated(true) setIsAdmin(authData.isAdmin) @@ -85,6 +95,10 @@ const AppContent: FC = () => { const closeSidebar = () => setIsSidebarOpen(false); + const handleKeyboardLayoutChange = () => { + fitCurrentTerminal(); + } + function handleKeyboardInput(input: string) { const currentTerminalTab = getTab(currentTab as number); if (currentTerminalTab && currentTerminalTab.terminalRef?.current?.sendInput) { @@ -95,7 +109,7 @@ const AppContent: FC = () => { if (authLoading) { return (
-

Loading...

+

{t('common.loading')}

) } @@ -126,7 +140,7 @@ const AppContent: FC = () => { {tabs.map(tab => (
{
))} {tabs.length === 0 && ( -
- Select a host to start a terminal session. +
+

+ {t('mobile.selectHostToStart')} +

+

+ {t('mobile.limitedSupportMessage')} +

)}
- {currentTab && } + {currentTab && +
+ +
+ } setIsSidebarOpen(true)} /> diff --git a/src/ui/Mobile/Navigation/BottomNavbar.tsx b/src/ui/Mobile/Navigation/BottomNavbar.tsx new file mode 100644 index 00000000..b2fea2d9 --- /dev/null +++ b/src/ui/Mobile/Navigation/BottomNavbar.tsx @@ -0,0 +1,48 @@ +import {Button} from "@/components/ui/button.tsx"; +import {Menu, X, Terminal as TerminalIcon} from "lucide-react"; +import {useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx"; +import {cn} from "@/lib/utils.ts"; + +interface MenuProps { + onSidebarOpenClick?: () => void; +} + +export function BottomNavbar({onSidebarOpenClick}: MenuProps) { + const {tabs, currentTab, setCurrentTab, removeTab} = useTabs(); + + return ( +
+
+ +
+
+ {tabs.map(tab => ( +
+ + +
+ ))} +
+
+
+
+ ) +} diff --git a/src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx b/src/ui/Mobile/Navigation/Hosts/FolderCard.tsx similarity index 97% rename from src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx rename to src/ui/Mobile/Navigation/Hosts/FolderCard.tsx index 9e63ddbc..85c9e214 100644 --- a/src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx +++ b/src/ui/Mobile/Navigation/Hosts/FolderCard.tsx @@ -3,7 +3,7 @@ import {CardTitle} from "@/components/ui/card.tsx"; import {ChevronDown, Folder} from "lucide-react"; import {Button} from "@/components/ui/button.tsx"; import {Separator} from "@/components/ui/separator.tsx"; -import {Host} from "@/ui/Mobile/Apps/Navigation/Hosts/Host.tsx"; +import {Host} from "@/ui/Mobile/Navigation/Hosts/Host.tsx"; interface SSHHost { id: number; diff --git a/src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx b/src/ui/Mobile/Navigation/Hosts/Host.tsx similarity index 97% rename from src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx rename to src/ui/Mobile/Navigation/Hosts/Host.tsx index 8145c7d8..bcbd2467 100644 --- a/src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx +++ b/src/ui/Mobile/Navigation/Hosts/Host.tsx @@ -4,7 +4,7 @@ import {Button} from "@/components/ui/button.tsx"; import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {Server, Terminal} from "lucide-react"; import {getServerStatusById} from "@/ui/main-axios.ts"; -import {useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx"; +import {useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx"; interface SSHHost { id: number; diff --git a/src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx b/src/ui/Mobile/Navigation/LeftSidebar.tsx similarity index 99% rename from src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx rename to src/ui/Mobile/Navigation/LeftSidebar.tsx index d98b968a..0da6c3d2 100644 --- a/src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx +++ b/src/ui/Mobile/Navigation/LeftSidebar.tsx @@ -10,7 +10,7 @@ import {Button} from "@/components/ui/button.tsx"; import {ChevronUp, Menu, User2} from "lucide-react"; import React, {useState, useEffect, useMemo, useCallback} from "react"; import {Separator} from "@/components/ui/separator.tsx"; -import {FolderCard} from "@/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx"; +import {FolderCard} from "@/ui/Mobile/Navigation/Hosts/FolderCard.tsx"; import {getSSHHosts} from "@/ui/main-axios.ts"; import {useTranslation} from "react-i18next"; import {Input} from "@/components/ui/input.tsx"; @@ -153,7 +153,7 @@ export function LeftSidebar({isSidebarOpen, setIsSidebarOpen, onHostConnect, dis -
+
setSearch(e.target.value)} diff --git a/src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx b/src/ui/Mobile/Navigation/Tabs/TabContext.tsx similarity index 100% rename from src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx rename to src/ui/Mobile/Navigation/Tabs/TabContext.tsx