v1.6.0 #221
8
package-lock.json
generated
8
package-lock.json
generated
@@ -73,6 +73,7 @@
|
|||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"validator": "^13.15.15",
|
"validator": "^13.15.15",
|
||||||
"ws": "^8.18.3",
|
"ws": "^8.18.3",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
"zod": "^4.0.5"
|
"zod": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -10830,6 +10831,13 @@
|
|||||||
"node": ">=0.4"
|
"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": {
|
"node_modules/yargs-parser": {
|
||||||
"version": "21.1.1",
|
"version": "21.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"validator": "^13.15.15",
|
"validator": "^13.15.15",
|
||||||
"ws": "^8.18.3",
|
"ws": "^8.18.3",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
"zod": "^4.0.5"
|
"zod": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -208,7 +208,7 @@
|
|||||||
"downloadSample": "Download Sample",
|
"downloadSample": "Download Sample",
|
||||||
"formatGuide": "Format Guide",
|
"formatGuide": "Format Guide",
|
||||||
"uncategorized": "Uncategorized",
|
"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",
|
"failedToDeleteHost": "Failed to delete host",
|
||||||
"jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts",
|
"jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts",
|
||||||
"noHostsInJson": "No hosts found in JSON file",
|
"noHostsInJson": "No hosts found in JSON file",
|
||||||
@@ -810,5 +810,9 @@
|
|||||||
"invalidVerificationCode": "Invalid verification code",
|
"invalidVerificationCode": "Invalid verification code",
|
||||||
"failedToDisableTotp": "Failed to disable TOTP",
|
"failedToDisableTotp": "Failed to disable TOTP",
|
||||||
"failedToGenerateBackupCodes": "Failed to generate backup codes"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,7 +614,7 @@
|
|||||||
"firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志,或创建",
|
"firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志,或创建",
|
||||||
"external": "外部",
|
"external": "外部",
|
||||||
"loginWithExternal": "使用外部提供商登录",
|
"loginWithExternal": "使用外部提供商登录",
|
||||||
"loginWithExternalDesc": "使用您配置的外部身份提供商登录",
|
"loginWithExternalDesc": "使用您配置的外部身份提供者登录",
|
||||||
"resetPasswordButton": "重置密码",
|
"resetPasswordButton": "重置密码",
|
||||||
"sendResetCode": "发送重置代码",
|
"sendResetCode": "发送重置代码",
|
||||||
"resetCodeDesc": "输入您的用户名以接收密码重置代码。代码将记录在 docker 容器日志中。",
|
"resetCodeDesc": "输入您的用户名以接收密码重置代码。代码将记录在 docker 容器日志中。",
|
||||||
@@ -852,5 +852,9 @@
|
|||||||
"invalidVerificationCode": "无效的验证码",
|
"invalidVerificationCode": "无效的验证码",
|
||||||
"failedToDisableTotp": "禁用 TOTP 失败",
|
"failedToDisableTotp": "禁用 TOTP 失败",
|
||||||
"failedToGenerateBackupCodes": "生成备用码失败"
|
"failedToGenerateBackupCodes": "生成备用码失败"
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"selectHostToStart": "选择一个主机以开始您的终端会话",
|
||||||
|
"limitedSupportMessage": "移动端支持目前有限。我们即将推出专门的移动应用以提升您的体验。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="w-full h-[80px] bg-[#18181B] flex items-center p-2 gap-2">
|
|
||||||
<Button className="w-[40px] h-[40px] flex-shrink-0" variant="outline" onClick={onSidebarOpenClick}>
|
|
||||||
<Menu/>
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1 overflow-x-auto whitespace-nowrap thin-scrollbar">
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
{tabs.map(tab => (
|
|
||||||
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-10 rounded-r-none !px-3 border-1 border-[#303032]",
|
|
||||||
tab.id === currentTab && '!bg-[#09090b] !text-white'
|
|
||||||
)}
|
|
||||||
onClick={() => setCurrentTab(tab.id)}
|
|
||||||
>
|
|
||||||
<TerminalIcon className="mr-1 h-4 w-4"/>
|
|
||||||
{tab.title}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 rounded-l-none !px-2 border-1 border-[#303032]"
|
|
||||||
onClick={() => removeTab(tab.id)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -97,17 +97,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
return () => window.removeEventListener('resize', handleWindowResize);
|
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() {
|
function handleWindowResize() {
|
||||||
if (!isVisibleRef.current) return;
|
if (!isVisibleRef.current) return;
|
||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
@@ -158,7 +147,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||||
|
|
||||||
terminal.options = {
|
terminal.options = {
|
||||||
cursorBlink: true,
|
cursorBlink: false,
|
||||||
cursorStyle: 'bar',
|
cursorStyle: 'bar',
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -173,6 +162,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
fastScrollModifier: 'alt',
|
fastScrollModifier: 'alt',
|
||||||
fastScrollSensitivity: 5,
|
fastScrollSensitivity: 5,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
|
disableStdin: true,
|
||||||
|
cursorInactiveStyle: "bar",
|
||||||
};
|
};
|
||||||
|
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
@@ -187,6 +178,14 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
terminal.loadAddon(webLinksAddon);
|
terminal.loadAddon(webLinksAddon);
|
||||||
terminal.open(xtermRef.current);
|
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(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
resizeTimeout.current = setTimeout(() => {
|
resizeTimeout.current = setTimeout(() => {
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import React, {useState} from "react";
|
import React, {useState, useCallback, useEffect} from "react";
|
||||||
import Keyboard from "react-simple-keyboard";
|
import Keyboard from "react-simple-keyboard";
|
||||||
import "react-simple-keyboard/build/css/index.css";
|
import "react-simple-keyboard/build/css/index.css";
|
||||||
import "./kb-dark-theme.css";
|
import "./kb-dark-theme.css";
|
||||||
|
|
||||||
interface TerminalKeyboardProps {
|
interface TerminalKeyboardProps {
|
||||||
onSendInput: (input: string) => void;
|
onSendInput: (input: string) => void;
|
||||||
|
onLayoutChange: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) {
|
export function TerminalKeyboard({onSendInput, onLayoutChange}: TerminalKeyboardProps) {
|
||||||
const [layoutName, setLayoutName] = useState("default");
|
const [layoutName, setLayoutName] = useState("default");
|
||||||
const [isCtrl, setIsCtrl] = useState(false);
|
const [isCtrl, setIsCtrl] = useState(false);
|
||||||
const [isAlt, setIsAlt] = 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}") {
|
if (button === "{shift}") {
|
||||||
setLayoutName("shift");
|
setLayoutName("shift");
|
||||||
return;
|
return;
|
||||||
@@ -47,20 +55,6 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) {
|
|||||||
return;
|
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;
|
let input = button;
|
||||||
|
|
||||||
const specialKeyMap: { [key: string]: string } = {
|
const specialKeyMap: { [key: string]: string } = {
|
||||||
@@ -90,8 +84,16 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) {
|
|||||||
input = `\x1b${input}`;
|
input = `\x1b${input}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(20);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Vibration failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
onSendInput(input);
|
onSendInput(input);
|
||||||
};
|
}, [onSendInput, isCtrl, isAlt]);
|
||||||
|
|
||||||
const buttonTheme = [
|
const buttonTheme = [
|
||||||
{
|
{
|
||||||
@@ -104,7 +106,7 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
class: "hg-space-small",
|
class: "hg-space-small",
|
||||||
buttons: "{hide} {less} {more}",
|
buttons: "{hide} {unhide} {less} {more}",
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -116,7 +118,7 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="z-10">
|
||||||
<Keyboard
|
<Keyboard
|
||||||
layout={{
|
layout={{
|
||||||
default: [
|
default: [
|
||||||
@@ -139,7 +141,7 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) {
|
|||||||
"! @ # $ % ^ & * ( ) _ +",
|
"! @ # $ % ^ & * ( ) _ +",
|
||||||
"[ ] { } | \\ ; : ' \" , . / < >",
|
"[ ] { } | \\ ; : ' \" , . / < >",
|
||||||
"F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
"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} {less} {space} {enter}",
|
||||||
],
|
],
|
||||||
hide: [
|
hide: [
|
||||||
@@ -166,7 +168,6 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) {
|
|||||||
"{tab}": "tab",
|
"{tab}": "tab",
|
||||||
"{ctrl}": "ctrl",
|
"{ctrl}": "ctrl",
|
||||||
"{alt}": "alt",
|
"{alt}": "alt",
|
||||||
"{paste}": "paste",
|
|
||||||
"{end}": "end",
|
"{end}": "end",
|
||||||
"{home}": "home",
|
"{home}": "home",
|
||||||
"{pgUp}": "pgUp",
|
"{pgUp}": "pgUp",
|
||||||
@@ -174,8 +175,9 @@ export function TerminalKeyboard({onSendInput}: TerminalKeyboardProps) {
|
|||||||
}}
|
}}
|
||||||
theme={"hg-theme-default dark-theme"}
|
theme={"hg-theme-default dark-theme"}
|
||||||
useTouchEvents={true}
|
useTouchEvents={true}
|
||||||
|
disableButtonHold={true}
|
||||||
buttonTheme={buttonTheme}
|
buttonTheme={buttonTheme}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hg-space-medium {
|
.hg-space-medium {
|
||||||
width: 60px;
|
width: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hg-space-small {
|
.hg-space-small {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, {useRef, FC, useState, useEffect} from "react";
|
import React, {useRef, FC, useState, useEffect} from "react";
|
||||||
import {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx";
|
import {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx";
|
||||||
import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx";
|
import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx";
|
||||||
import {BottomNavbar} from "@/ui/Mobile/Apps/Navigation/BottomNavbar.tsx";
|
import {BottomNavbar} from "@/ui/Mobile/Navigation/BottomNavbar.tsx";
|
||||||
import {LeftSidebar} from "@/ui/Mobile/Apps/Navigation/LeftSidebar.tsx";
|
import {LeftSidebar} from "@/ui/Mobile/Navigation/LeftSidebar.tsx";
|
||||||
import {TabProvider, useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx";
|
import {TabProvider, useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx";
|
||||||
import {getUserInfo} from "@/ui/main-axios.ts";
|
import {getUserInfo} from "@/ui/main-axios.ts";
|
||||||
import {HomepageAuth} from "@/ui/Mobile/Homepage/HomepageAuth.tsx";
|
import {HomepageAuth} from "@/ui/Mobile/Homepage/HomepageAuth.tsx";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
|
||||||
function getCookie(name: string) {
|
function getCookie(name: string) {
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
@@ -15,6 +16,7 @@ function getCookie(name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AppContent: FC = () => {
|
const AppContent: FC = () => {
|
||||||
|
const {t} = useTranslation();
|
||||||
const {tabs, currentTab, getTab} = useTabs();
|
const {tabs, currentTab, getTab} = useTabs();
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
|
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
|
||||||
const [ready, setReady] = React.useState(true);
|
const [ready, setReady] = React.useState(true);
|
||||||
@@ -58,6 +60,14 @@ const AppContent: FC = () => {
|
|||||||
return () => window.removeEventListener('storage', handleStorageChange)
|
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 }) => {
|
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
setIsAdmin(authData.isAdmin)
|
setIsAdmin(authData.isAdmin)
|
||||||
@@ -85,6 +95,10 @@ const AppContent: FC = () => {
|
|||||||
|
|
||||||
const closeSidebar = () => setIsSidebarOpen(false);
|
const closeSidebar = () => setIsSidebarOpen(false);
|
||||||
|
|
||||||
|
const handleKeyboardLayoutChange = () => {
|
||||||
|
fitCurrentTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeyboardInput(input: string) {
|
function handleKeyboardInput(input: string) {
|
||||||
const currentTerminalTab = getTab(currentTab as number);
|
const currentTerminalTab = getTab(currentTab as number);
|
||||||
if (currentTerminalTab && currentTerminalTab.terminalRef?.current?.sendInput) {
|
if (currentTerminalTab && currentTerminalTab.terminalRef?.current?.sendInput) {
|
||||||
@@ -95,7 +109,7 @@ const AppContent: FC = () => {
|
|||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen flex items-center justify-center bg-[#09090b]">
|
<div className="h-screen w-screen flex items-center justify-center bg-[#09090b]">
|
||||||
<p className="text-white">Loading...</p>
|
<p className="text-white">{t('common.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -126,7 +140,7 @@ const AppContent: FC = () => {
|
|||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className="absolute inset-0"
|
className="absolute inset-0 mb-2"
|
||||||
style={{
|
style={{
|
||||||
visibility: tab.id === currentTab ? 'visible' : 'hidden',
|
visibility: tab.id === currentTab ? 'visible' : 'hidden',
|
||||||
opacity: ready ? 1 : 0,
|
opacity: ready ? 1 : 0,
|
||||||
@@ -140,12 +154,21 @@ const AppContent: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{tabs.length === 0 && (
|
{tabs.length === 0 && (
|
||||||
<div className="flex items-center justify-center h-full text-white">
|
<div className="flex flex-col items-center justify-center h-full text-white gap-3 px-4 text-center">
|
||||||
Select a host to start a terminal session.
|
<h1 className="text-lg font-semibold">
|
||||||
|
{t('mobile.selectHostToStart')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-300 max-w-xs">
|
||||||
|
{t('mobile.limitedSupportMessage')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{currentTab && <TerminalKeyboard onSendInput={handleKeyboardInput}/>}
|
{currentTab &&
|
||||||
|
<div className="mb-1 z-10">
|
||||||
|
<TerminalKeyboard onSendInput={handleKeyboardInput} onLayoutChange={handleKeyboardLayoutChange}/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<BottomNavbar
|
<BottomNavbar
|
||||||
onSidebarOpenClick={() => setIsSidebarOpen(true)}
|
onSidebarOpenClick={() => setIsSidebarOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
48
src/ui/Mobile/Navigation/BottomNavbar.tsx
Normal file
48
src/ui/Mobile/Navigation/BottomNavbar.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="w-full h-[50px] bg-[#18181B] items-center p-1">
|
||||||
|
<div className="flex gap-2 !mb-0.5">
|
||||||
|
<Button className="w-[40px] h-[40px] flex-shrink-0" variant="outline" onClick={onSidebarOpenClick}>
|
||||||
|
<Menu/>
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 overflow-x-auto whitespace-nowrap thin-scrollbar">
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-10 rounded-r-none !px-3 border-1 border-[#303032]",
|
||||||
|
tab.id === currentTab && '!bg-[#09090b] !text-white'
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentTab(tab.id)}
|
||||||
|
>
|
||||||
|
<TerminalIcon className="mr-1 h-4 w-4"/>
|
||||||
|
{tab.title}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 rounded-l-none !px-2 border-1 border-[#303032]"
|
||||||
|
onClick={() => removeTab(tab.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import {CardTitle} from "@/components/ui/card.tsx";
|
|||||||
import {ChevronDown, Folder} from "lucide-react";
|
import {ChevronDown, Folder} from "lucide-react";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Separator} from "@/components/ui/separator.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 {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -4,7 +4,7 @@ import {Button} from "@/components/ui/button.tsx";
|
|||||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
import {Server, Terminal} from "lucide-react";
|
import {Server, Terminal} from "lucide-react";
|
||||||
import {getServerStatusById} from "@/ui/main-axios.ts";
|
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 {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -10,7 +10,7 @@ import {Button} from "@/components/ui/button.tsx";
|
|||||||
import {ChevronUp, Menu, User2} from "lucide-react";
|
import {ChevronUp, Menu, User2} from "lucide-react";
|
||||||
import React, {useState, useEffect, useMemo, useCallback} from "react";
|
import React, {useState, useEffect, useMemo, useCallback} from "react";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
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 {getSSHHosts} from "@/ui/main-axios.ts";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
@@ -153,7 +153,7 @@ export function LeftSidebar({isSidebarOpen, setIsSidebarOpen, onHostConnect, dis
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<SidebarContent className="px-2 py-2">
|
<SidebarContent className="px-2 py-2">
|
||||||
<div className="!bg-[#222225] rounded-lg mb-2">
|
<div className="!bg-[#222225] rounded-lg">
|
||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
Reference in New Issue
Block a user