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:
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>
|
||||
)
|
||||
}
|
||||
81
src/ui/Mobile/Navigation/Hosts/FolderCard.tsx
Normal file
81
src/ui/Mobile/Navigation/Hosts/FolderCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
src/ui/Mobile/Navigation/Hosts/Host.tsx
Normal file
107
src/ui/Mobile/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";
|
||||
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>
|
||||
)
|
||||
}
|
||||
227
src/ui/Mobile/Navigation/LeftSidebar.tsx
Normal file
227
src/ui/Mobile/Navigation/LeftSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
src/ui/Mobile/Navigation/Tabs/TabContext.tsx
Normal file
100
src/ui/Mobile/Navigation/Tabs/TabContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user