Mobile support (#190)

* Add vibration to keyboard

* Fix keyboard keys

* Fix keyboard keys

* Fix keyboard keys

* Rename files, improve keyboard usability

* Improve keyboard view and fix various issues with it

* Add mobile chinese translation

* Disable OS keyboard from appearing

* Fix fit addon not resizing with "more" on keyboard

* Disable OS keyboard on terminal load
This commit was merged in pull request #190.
This commit is contained in:
Karmaa
2025-09-07 21:23:16 -05:00
committed by GitHub
parent fee5961482
commit 60928ae191
15 changed files with 143 additions and 96 deletions

View 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>
)
}

View File

@@ -0,0 +1,81 @@
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/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[];
onHostConnect: () => void;
}
export function FolderCard({folderName, hosts, onHostConnect}: 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} onHostConnect={onHostConnect}/>
{index < hosts.length - 1 && (
<div className="relative -mx-2">
<Separator className="p-0.25 absolute inset-x-0"/>
</div>
)}
</React.Fragment>
))}
</div>
)}
</div>
)
}

View 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";
import {useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.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 HostProps {
host: SSHHost;
onHostConnect: () => void;
}
export function Host({host, onHostConnect}: HostProps): React.ReactElement {
const {addTab} = useTabs();
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 = () => {
addTab({type: 'terminal', title, hostConfig: host});
onHostConnect();
};
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>
)
}

View File

@@ -0,0 +1,227 @@
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroupLabel,
SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
SidebarProvider
} from "@/components/ui/sidebar.tsx";
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/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next";
import {Input} from "@/components/ui/input.tsx";
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
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 LeftSidebarProps {
isSidebarOpen: boolean;
setIsSidebarOpen: (type: boolean) => void;
onHostConnect: () => void;
disabled?: boolean;
username?: string | null;
}
function handleLogout() {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
window.location.reload();
}
export function LeftSidebar({isSidebarOpen, setIsSidebarOpen, onHostConnect, disabled, username}: LeftSidebarProps) {
const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
const prevHostsRef = React.useRef<SSHHost[]>([]);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const fetchHosts = useCallback(async () => {
try {
const newHosts = await getSSHHosts();
const prevHosts = prevHostsRef.current;
if (JSON.stringify(newHosts) !== JSON.stringify(prevHosts)) {
setHosts(newHosts);
prevHostsRef.current = newHosts;
}
} catch (err: any) {
setHostsError(t('leftSidebar.failedToLoadHosts'));
}
}, [t]);
useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 300000);
return () => clearInterval(interval);
}, [fetchHosts]);
useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
}, [fetchHosts]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
const filteredHosts = useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
return hosts.filter(h => {
const searchableText = [
h.name || '',
h.username,
h.ip,
h.folder || '',
...(h.tags || []),
].join(' ').toLowerCase();
return searchableText.includes(q);
});
}, [hosts, debouncedSearch]);
const hostsByFolder = useMemo(() => {
const map: Record<string, SSHHost[]> = {};
filteredHosts.forEach(h => {
const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder');
if (!map[folder]) map[folder] = [];
map[folder].push(h);
});
return map;
}, [filteredHosts, t]);
const sortedFolders = useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === t('leftSidebar.noFolder')) return 1;
if (b === t('leftSidebar.noFolder')) return -1;
return a.localeCompare(b);
});
return folders;
}, [hostsByFolder, t]);
const getSortedHosts = useCallback((arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
}, []);
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">
<div className="!bg-[#222225] rounded-lg">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('placeholders.searchHostsAny')}
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
autoComplete="off"
/>
</div>
{hostsError && (
<div className="px-1">
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
{t('leftSidebar.failedToLoadHosts')}
</div>
</div>
)}
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
{t('hosts.loadingHosts')}
</div>
</div>
)}
{sortedFolders.map((folder) => (
<FolderCard
key={`folder-${folder}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
onHostConnect={onHostConnect}
/>
))}
</SidebarContent>
<Separator className="mt-1"/>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
style={{width: '100%'}}
disabled={disabled}
>
<User2/> {username ? username : t('common.logout')}
<ChevronUp className="ml-auto"/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
<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>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
</SidebarProvider>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
import {useTranslation} from 'react-i18next';
export interface Tab {
id: number;
type: 'terminal';
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
}
interface TabContextType {
tabs: Tab[];
currentTab: number | null;
addTab: (tab: Omit<Tab, 'id'>) => number;
removeTab: (tabId: number) => void;
setCurrentTab: (tabId: number) => void;
getTab: (tabId: number) => Tab | undefined;
}
const TabContext = createContext<TabContextType | undefined>(undefined);
export function useTabs() {
const context = useContext(TabContext);
if (context === undefined) {
throw new Error('useTabs must be used within a TabProvider');
}
return context;
}
interface TabProviderProps {
children: ReactNode;
}
export function TabProvider({children}: TabProviderProps) {
const {t} = useTranslation();
const [tabs, setTabs] = useState<Tab[]>([]);
const [currentTab, setCurrentTab] = useState<number | null>(null);
const nextTabId = useRef(1);
function computeUniqueTitle(desiredTitle: string | undefined): string {
const baseTitle = (desiredTitle || 'Terminal').trim();
const existingTitles = tabs.map(t => t.title);
if (!existingTitles.includes(baseTitle)) {
return baseTitle;
}
let i = 2;
while (existingTitles.includes(`${baseTitle} (${i})`)) {
i++;
}
return `${baseTitle} (${i})`;
}
const addTab = (tabData: Omit<Tab, 'id'>): number => {
const id = nextTabId.current++;
const newTab: Tab = {
...tabData,
id,
title: computeUniqueTitle(tabData.title),
terminalRef: React.createRef<any>()
};
setTabs(prev => [...prev, newTab]);
setCurrentTab(id);
return id;
};
const removeTab = (tabId: number) => {
const tab = tabs.find(t => t.id === tabId);
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
tab.terminalRef.current.disconnect();
}
setTabs(prev => {
const newTabs = prev.filter(tab => tab.id !== tabId);
if (currentTab === tabId) {
setCurrentTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null);
}
return newTabs;
});
};
const getTab = (tabId: number) => {
return tabs.find(tab => tab.id === tabId);
};
const value: TabContextType = {
tabs,
currentTab,
addTab,
removeTab,
setCurrentTab,
getTab,
};
return (
<TabContext.Provider value={value}>
{children}
</TabContext.Provider>
);
}