mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
|
|
|
import { StatusBar } from './StatusBar';
|
|
import { Sidebar } from './Sidebar';
|
|
import { ConversationPane } from './ConversationPane';
|
|
import { NewMessageModal } from './NewMessageModal';
|
|
import { ContactInfoPane } from './ContactInfoPane';
|
|
import { ChannelInfoPane } from './ChannelInfoPane';
|
|
import { Toaster } from './ui/sonner';
|
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
|
import {
|
|
SETTINGS_SECTION_ICONS,
|
|
SETTINGS_SECTION_LABELS,
|
|
SETTINGS_SECTION_ORDER,
|
|
type SettingsSection,
|
|
} from './settings/settingsConstants';
|
|
import { getContrastTextColor, type LocalLabel } from '../utils/localLabel';
|
|
import type { CrackerPanelProps } from './CrackerPanel';
|
|
import type { SearchViewProps } from './SearchView';
|
|
import type { SettingsModalProps } from './SettingsModal';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const SettingsModal = lazy(() =>
|
|
import('./SettingsModal').then((m) => ({ default: m.SettingsModal }))
|
|
);
|
|
const CrackerPanel = lazy(() =>
|
|
import('./CrackerPanel').then((m) => ({ default: m.CrackerPanel }))
|
|
);
|
|
const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.SearchView })));
|
|
|
|
type SidebarProps = ComponentProps<typeof Sidebar>;
|
|
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
|
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
|
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
|
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
|
|
|
interface AppShellProps {
|
|
localLabel: LocalLabel;
|
|
showNewMessage: boolean;
|
|
showSettings: boolean;
|
|
settingsSection: SettingsSection;
|
|
sidebarOpen: boolean;
|
|
showCracker: boolean;
|
|
onSettingsSectionChange: (section: SettingsSection) => void;
|
|
onSidebarOpenChange: (open: boolean) => void;
|
|
onCrackerRunningChange: (running: boolean) => void;
|
|
onToggleSettingsView: () => void;
|
|
onCloseSettingsView: () => void;
|
|
onCloseNewMessage: () => void;
|
|
onLocalLabelChange: (label: LocalLabel) => void;
|
|
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
|
sidebarProps: SidebarProps;
|
|
conversationPaneProps: ConversationPaneProps;
|
|
searchProps: SearchViewProps;
|
|
settingsProps: Omit<
|
|
SettingsModalProps,
|
|
'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange'
|
|
>;
|
|
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
|
newMessageModalProps: NewMessageModalProps;
|
|
contactInfoPaneProps: ContactInfoPaneProps;
|
|
channelInfoPaneProps: ChannelInfoPaneProps;
|
|
}
|
|
|
|
export function AppShell({
|
|
localLabel,
|
|
showNewMessage,
|
|
showSettings,
|
|
settingsSection,
|
|
sidebarOpen,
|
|
showCracker,
|
|
onSettingsSectionChange,
|
|
onSidebarOpenChange,
|
|
onCrackerRunningChange,
|
|
onToggleSettingsView,
|
|
onCloseSettingsView,
|
|
onCloseNewMessage,
|
|
onLocalLabelChange,
|
|
statusProps,
|
|
sidebarProps,
|
|
conversationPaneProps,
|
|
searchProps,
|
|
settingsProps,
|
|
crackerProps,
|
|
newMessageModalProps,
|
|
contactInfoPaneProps,
|
|
channelInfoPaneProps,
|
|
}: AppShellProps) {
|
|
const searchMounted = useRef(false);
|
|
if (conversationPaneProps.activeConversation?.type === 'search') {
|
|
searchMounted.current = true;
|
|
}
|
|
|
|
const crackerMounted = useRef(false);
|
|
if (showCracker) {
|
|
crackerMounted.current = true;
|
|
}
|
|
|
|
const settingsSidebarContent = (
|
|
<nav
|
|
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
|
aria-label="Settings"
|
|
>
|
|
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
|
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
|
Settings
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onCloseSettingsView}
|
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-status-connected/15 border border-status-connected/30 text-status-connected hover:bg-status-connected/25 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
title="Back to conversations"
|
|
aria-label="Back to conversations"
|
|
>
|
|
← Back to Chat
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
|
|
{SETTINGS_SECTION_ORDER.map((section) => {
|
|
const Icon = SETTINGS_SECTION_ICONS[section];
|
|
return (
|
|
<button
|
|
key={section}
|
|
type="button"
|
|
className={cn(
|
|
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
|
settingsSection === section && 'bg-accent border-l-primary'
|
|
)}
|
|
aria-current={settingsSection === section ? 'true' : undefined}
|
|
onClick={() => onSettingsSectionChange(section)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
|
<span>{SETTINGS_SECTION_LABELS[section]}</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
);
|
|
|
|
const activeSidebarContent = showSettings ? (
|
|
settingsSidebarContent
|
|
) : (
|
|
<Sidebar {...sidebarProps} />
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<a
|
|
href="#main-content"
|
|
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
|
|
>
|
|
Skip to content
|
|
</a>
|
|
{localLabel.text && (
|
|
<div
|
|
style={{
|
|
backgroundColor: localLabel.color,
|
|
color: getContrastTextColor(localLabel.color),
|
|
}}
|
|
className="px-4 py-1 text-center text-sm font-medium"
|
|
>
|
|
{localLabel.text}
|
|
</div>
|
|
)}
|
|
|
|
<StatusBar
|
|
health={statusProps.health}
|
|
config={statusProps.config}
|
|
settingsMode={showSettings}
|
|
onSettingsClick={onToggleSettingsView}
|
|
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
|
/>
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
|
|
|
<Sheet open={sidebarOpen} onOpenChange={onSidebarOpenChange}>
|
|
<SheetContent
|
|
side="left"
|
|
className="w-[280px] p-0 flex flex-col"
|
|
hideCloseButton
|
|
onOpenAutoFocus={(event) => {
|
|
event.preventDefault();
|
|
}}
|
|
>
|
|
<SheetHeader className="sr-only">
|
|
<SheetTitle>Navigation</SheetTitle>
|
|
<SheetDescription>Sidebar navigation</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
<main id="main-content" className="flex-1 flex flex-col bg-background min-w-0">
|
|
<div
|
|
className={cn(
|
|
'flex-1 flex flex-col min-h-0',
|
|
(showSettings || conversationPaneProps.activeConversation?.type === 'search') &&
|
|
'hidden'
|
|
)}
|
|
>
|
|
<ConversationPane {...conversationPaneProps} />
|
|
</div>
|
|
|
|
{searchMounted.current && (
|
|
<div
|
|
className={cn(
|
|
'flex-1 flex flex-col min-h-0',
|
|
(conversationPaneProps.activeConversation?.type !== 'search' || showSettings) &&
|
|
'hidden'
|
|
)}
|
|
>
|
|
<Suspense
|
|
fallback={
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
Loading search...
|
|
</div>
|
|
}
|
|
>
|
|
<SearchView {...searchProps} />
|
|
</Suspense>
|
|
</div>
|
|
)}
|
|
|
|
{showSettings && (
|
|
<div className="flex-1 flex flex-col min-h-0">
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
<Suspense
|
|
fallback={
|
|
<div className="flex-1 flex items-center justify-center p-8 text-muted-foreground">
|
|
Loading settings...
|
|
</div>
|
|
}
|
|
>
|
|
<SettingsModal
|
|
{...settingsProps}
|
|
open={showSettings}
|
|
pageMode
|
|
externalSidebarNav
|
|
desktopSection={settingsSection}
|
|
onClose={onCloseSettingsView}
|
|
onLocalLabelChange={onLocalLabelChange}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
<div
|
|
ref={(el) => {
|
|
if (showCracker && el) {
|
|
const focusable = el.querySelector<HTMLElement>('input, button:not([disabled])');
|
|
if (focusable) {
|
|
setTimeout(() => focusable.focus(), 210);
|
|
}
|
|
}
|
|
}}
|
|
className={cn(
|
|
'border-t border-border bg-background transition-all duration-200 overflow-hidden',
|
|
showCracker ? 'h-[275px]' : 'h-0'
|
|
)}
|
|
>
|
|
{crackerMounted.current && (
|
|
<Suspense
|
|
fallback={
|
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
Loading cracker...
|
|
</div>
|
|
}
|
|
>
|
|
<CrackerPanel
|
|
{...crackerProps}
|
|
visible={showCracker}
|
|
onRunningChange={onCrackerRunningChange}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</div>
|
|
|
|
<NewMessageModal
|
|
{...newMessageModalProps}
|
|
open={showNewMessage}
|
|
onClose={onCloseNewMessage}
|
|
onSelectConversation={(conv) => {
|
|
newMessageModalProps.onSelectConversation(conv);
|
|
onCloseNewMessage();
|
|
}}
|
|
/>
|
|
|
|
<ContactInfoPane {...contactInfoPaneProps} />
|
|
<ChannelInfoPane {...channelInfoPaneProps} />
|
|
<Toaster position="top-right" />
|
|
</div>
|
|
);
|
|
}
|