v1.6.0 #221
21
package-lock.json
generated
21
package-lock.json
generated
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "1.6.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
@@ -5152,26 +5151,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/compare-version": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz",
|
||||
"integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
|
||||
@@ -388,6 +388,7 @@ export function LeftSidebar({
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={handleLogout}>
|
||||
|
||||
<span>{t('common.logout')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -59,13 +59,9 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
setSelectedTabIds([]);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (selectedTabIds.length === 0) return;
|
||||
|
||||
const value = e.currentTarget.value;
|
||||
let commandToSend = '';
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
|
||||
16
src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx
Normal file
16
src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {Menu} from "lucide-react";
|
||||
|
||||
interface MenuProps {
|
||||
onSidebarOpenClick?: () => void;
|
||||
}
|
||||
|
||||
export function BottomNavbar({onSidebarOpenClick}: MenuProps) {
|
||||
return (
|
||||
<div className="w-full h-[80px] bg-[#18181BFF] flex flex-col justify-center">
|
||||
<Button className="w-[40px] h-[40px] ml-2" variant="outline" onClick={onSidebarOpenClick}>
|
||||
<Menu />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx
Normal file
80
src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, {useState} from "react";
|
||||
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";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface FolderCardProps {
|
||||
folderName: string,
|
||||
hosts: SSHHost[],
|
||||
}
|
||||
|
||||
export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactElement {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
|
||||
style={{padding: '0', margin: '0'}}>
|
||||
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
||||
<div className="flex gap-2 pr-10">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<Folder size={16} strokeWidth={3}/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
|
||||
</Button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col p-2 gap-y-3">
|
||||
{hosts.map((host, index) => (
|
||||
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||
<Host host={host}/>
|
||||
|
||||
{index < hosts.length - 1 && (
|
||||
<div className="relative -mx-2">
|
||||
<Separator className="p-0.25 absolute inset-x-0"/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx
Normal file
107
src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||
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";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface HostProps {
|
||||
host: SSHHost;
|
||||
}
|
||||
|
||||
export function Host({host}: HostProps): React.ReactElement {
|
||||
const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded');
|
||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||
const hasTags = tags.length > 0;
|
||||
|
||||
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(host.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setServerStatus('offline');
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
|
||||
intervalId = window.setInterval(fetchStatus, 10000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [host.id]);
|
||||
|
||||
const handleTerminalClick = () => {
|
||||
|
||||
};
|
||||
|
||||
const handleServerClick = () => {
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||
<StatusIndicator/>
|
||||
</Status>
|
||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||
{host.name || host.ip}
|
||||
</p>
|
||||
<ButtonGroup className="flex-shrink-0">
|
||||
{host.enableTerminal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 w-[60px] border-[#303032]"
|
||||
onClick={handleTerminalClick}
|
||||
>
|
||||
<Terminal/>
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{hasTags && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{tags.map((tag: string) => (
|
||||
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
|
||||
<p className="text-sm">{tag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx
Normal file
63
src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import {Sidebar, SidebarContent, SidebarGroupLabel, SidebarHeader, SidebarProvider} from "@/components/ui/sidebar.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Menu} from "lucide-react";
|
||||
import React from "react";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {FolderCard} from "@/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx";
|
||||
import {Host} from "@/ui/Mobile/Apps/Navigation/Hosts/Host.tsx";
|
||||
|
||||
interface LeftSidebarProps {
|
||||
isSidebarOpen: boolean;
|
||||
setIsSidebarOpen: (type: boolean) => void;
|
||||
}
|
||||
|
||||
export function LeftSidebar({ isSidebarOpen, setIsSidebarOpen }: LeftSidebarProps) {
|
||||
return (
|
||||
<div className="">
|
||||
<SidebarProvider open={isSidebarOpen}>
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||
Termix
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="w-[28px] h-[28px] absolute right-5"
|
||||
>
|
||||
<Menu className="h-4 w-4"/>
|
||||
</Button>
|
||||
</SidebarGroupLabel>
|
||||
</SidebarHeader>
|
||||
<Separator/>
|
||||
<SidebarContent className="px-2 py-2">
|
||||
<FolderCard
|
||||
folderName="Folder"
|
||||
hosts={[{
|
||||
id: 1,
|
||||
name: "My Server",
|
||||
ip: "192.168.1.100",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
folder: "/home/admin",
|
||||
tags: ["production", "backend"],
|
||||
pin: true,
|
||||
authType: "password",
|
||||
password: "securePassword123",
|
||||
key: undefined,
|
||||
keyPassword: undefined,
|
||||
keyType: undefined,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/home/admin/projects",
|
||||
tunnelConnections: [],
|
||||
createdAt: "2025-09-05T12:00:00Z",
|
||||
updatedAt: "2025-09-05T12:00:00Z"
|
||||
}]}
|
||||
/>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import {useRef, FC} from "react";
|
||||
import React, {useRef, FC} 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";
|
||||
|
||||
export const MobileApp: FC = () => {
|
||||
const terminalRef = useRef<any>(null);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = React.useState(false);
|
||||
|
||||
function handleKeyboardInput(input: string) {
|
||||
if (!terminalRef.current?.sendInput) return;
|
||||
@@ -12,7 +15,7 @@ export const MobileApp: FC = () => {
|
||||
"{backspace}": "\x7f",
|
||||
"{space}": " ",
|
||||
"{tab}": "\t",
|
||||
"{enter}": "\r",
|
||||
"{enter}": "",
|
||||
"{escape}": "\x1b",
|
||||
"{arrowUp}": "\x1b[A",
|
||||
"{arrowDown}": "\x1b[B",
|
||||
@@ -22,7 +25,7 @@ export const MobileApp: FC = () => {
|
||||
"{home}": "\x1b[H",
|
||||
"{end}": "\x1b[F",
|
||||
"{pageUp}": "\x1b[5~",
|
||||
"{pageDown}": "\x1b[6~",
|
||||
"{pageDown}": "\x1b[6~"
|
||||
};
|
||||
|
||||
if (input in keyMap) {
|
||||
@@ -33,7 +36,7 @@ export const MobileApp: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-[#09090b] overflow-y-hidden overflow-x-hidden">
|
||||
<div className="h-screen w-screen flex flex-col bg-[#09090b] overflow-y-hidden overflow-x-hidden relative">
|
||||
<div className="flex-1 min-h-0">
|
||||
<Terminal
|
||||
ref={terminalRef}
|
||||
@@ -49,9 +52,25 @@ export const MobileApp: FC = () => {
|
||||
<TerminalKeyboard
|
||||
onSendInput={handleKeyboardInput}
|
||||
/>
|
||||
<div className="w-full h-[80px] bg-[#18181BFF]">
|
||||
<BottomNavbar
|
||||
onSidebarOpenClick={() => setIsSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30 backdrop-blur-sm z-10"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute top-0 left-0 h-full z-20 pointer-events-none">
|
||||
<div onClick={(e) => { e.stopPropagation(); }} className="pointer-events-auto">
|
||||
<LeftSidebar
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
setIsSidebarOpen={setIsSidebarOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user