refactor: repartition server-side and client-side code
This commit is contained in:
331
app/.client/components/@settings/core/ControlPanel.tsx
Normal file
331
app/.client/components/@settings/core/ControlPanel.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import classNames from 'classnames';
|
||||
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { TabTile } from '~/.client/components/@settings/core/TabTile';
|
||||
import DebugTab from '~/.client/components/@settings/tabs/debug/DebugTab';
|
||||
import { EventLogsTab } from '~/.client/components/@settings/tabs/event-logs/EventLogsTab';
|
||||
import NotificationsTab from '~/.client/components/@settings/tabs/notifications/NotificationsTab';
|
||||
import SettingsTab from '~/.client/components/@settings/tabs/settings/SettingsTab';
|
||||
import TaskManagerTab from '~/.client/components/@settings/tabs/task-manager/TaskManagerTab';
|
||||
import BackgroundRays from '~/.client/components/ui/BackgroundRays';
|
||||
import { useDebugStatus } from '~/.client/hooks/useDebugStatus';
|
||||
import { useNotifications } from '~/.client/hooks/useNotifications';
|
||||
import { profileStore } from '~/.client/stores/profile';
|
||||
import { resetTabConfiguration, tabConfigurationStore } from '~/.client/stores/settings';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { AvatarDropdown } from './AvatarDropdown';
|
||||
import { DEFAULT_TAB_CONFIG, TAB_DESCRIPTIONS } from './constants';
|
||||
import type { Profile, TabType, TabVisibilityConfig } from './types';
|
||||
|
||||
const logger = createScopedLogger('ControlPanel');
|
||||
|
||||
interface ControlPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface TabWithDevType extends TabVisibilityConfig {
|
||||
isExtraDevTab?: boolean;
|
||||
}
|
||||
|
||||
interface ExtendedTabConfig extends TabVisibilityConfig {
|
||||
isExtraDevTab?: boolean;
|
||||
}
|
||||
|
||||
interface BaseTabConfig {
|
||||
id: TabType;
|
||||
visible: boolean;
|
||||
window: 'user' | 'developer';
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
// State
|
||||
const [activeTab, setActiveTab] = useState<TabType | null>(null);
|
||||
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
|
||||
|
||||
// Store values
|
||||
const tabConfiguration = useStore(tabConfigurationStore);
|
||||
const profile = useStore(profileStore) as Profile;
|
||||
|
||||
// Status hooks
|
||||
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
|
||||
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
|
||||
|
||||
// Memoize the base tab configurations to avoid recalculation
|
||||
const baseTabConfig = useMemo(() => {
|
||||
return new Map(DEFAULT_TAB_CONFIG.map((tab) => [tab.id, tab]));
|
||||
}, []);
|
||||
|
||||
// Add visibleTabs logic using useMemo with optimized calculations
|
||||
const visibleTabs = useMemo(() => {
|
||||
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
|
||||
logger.warn('Invalid tab configuration, resetting to defaults');
|
||||
resetTabConfiguration();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenTabs = new Set<TabType>();
|
||||
const devTabs: ExtendedTabConfig[] = [];
|
||||
|
||||
// Process tabs in order of priority: developer, user, default
|
||||
const processTab = (tab: BaseTabConfig) => {
|
||||
if (!seenTabs.has(tab.id)) {
|
||||
seenTabs.add(tab.id);
|
||||
devTabs.push({
|
||||
id: tab.id,
|
||||
visible: true,
|
||||
window: 'developer',
|
||||
order: tab.order || devTabs.length,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Process tabs in priority order
|
||||
tabConfiguration.developerTabs?.forEach((tab: any) => processTab(tab as BaseTabConfig));
|
||||
tabConfiguration.userTabs.forEach((tab: any) => processTab(tab as BaseTabConfig));
|
||||
DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig));
|
||||
|
||||
return devTabs.sort((a, b) => a.order - b.order);
|
||||
}, [tabConfiguration, profile?.preferences?.notifications, baseTabConfig]);
|
||||
|
||||
// Optimize animation performance with layout animations
|
||||
const gridLayoutVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.8 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
mass: 0.6,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Reset to default view when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Reset when closing
|
||||
setActiveTab(null);
|
||||
setLoadingTab(null);
|
||||
} else {
|
||||
// When opening, set to null to show the main view
|
||||
setActiveTab(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Handle closing
|
||||
const handleClose = () => {
|
||||
setActiveTab(null);
|
||||
setLoadingTab(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Handlers
|
||||
const handleBack = () => {
|
||||
setActiveTab(null);
|
||||
};
|
||||
|
||||
const getTabComponent = (tabId: TabType) => {
|
||||
switch (tabId) {
|
||||
case 'settings':
|
||||
return <SettingsTab />;
|
||||
case 'notifications':
|
||||
return <NotificationsTab />;
|
||||
case 'debug':
|
||||
return <DebugTab />;
|
||||
case 'event-logs':
|
||||
return <EventLogsTab />;
|
||||
case 'task-manager':
|
||||
return <TaskManagerTab />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTabUpdateStatus = (tabId: TabType): boolean => {
|
||||
switch (tabId) {
|
||||
case 'notifications':
|
||||
return hasUnreadNotifications;
|
||||
case 'debug':
|
||||
return hasActiveWarnings;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusMessage = (tabId: TabType): string => {
|
||||
switch (tabId) {
|
||||
case 'notifications':
|
||||
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
|
||||
case 'debug': {
|
||||
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
|
||||
const errors = activeIssues.filter((i) => i.type === 'error').length;
|
||||
|
||||
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClick = (tabId: TabType) => {
|
||||
setLoadingTab(tabId);
|
||||
setActiveTab(tabId);
|
||||
|
||||
// Acknowledge notifications based on tab
|
||||
switch (tabId) {
|
||||
case 'notifications':
|
||||
markAllAsRead();
|
||||
break;
|
||||
case 'debug':
|
||||
acknowledgeAllIssues();
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear loading state after a delay
|
||||
setTimeout(() => setLoadingTab(null), 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadixDialog.Root open={open}>
|
||||
<RadixDialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</RadixDialog.Overlay>
|
||||
|
||||
<RadixDialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'w-[1200px] h-[90vh]',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'rounded-2xl shadow-2xl',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'flex flex-col overflow-hidden',
|
||||
'relative',
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden rounded-2xl">
|
||||
<BackgroundRays />
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-4">
|
||||
{activeTab && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
>
|
||||
<div className="i-ph:arrow-left size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Avatar and Dropdown */}
|
||||
<div className="border-l border-gray-200 dark:border-gray-800 pl-6">
|
||||
<AvatarDropdown onSelectTab={handleTabClick} />
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={classNames(
|
||||
'flex-1',
|
||||
'overflow-y-auto',
|
||||
'hover:overflow-y-auto',
|
||||
'scrollbar scrollbar-w-2',
|
||||
'scrollbar-track-transparent',
|
||||
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
|
||||
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
|
||||
'will-change-scroll',
|
||||
'touch-auto',
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
key={activeTab || 'home'}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="p-6"
|
||||
>
|
||||
{activeTab ? (
|
||||
getTabComponent(activeTab)
|
||||
) : (
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative"
|
||||
variants={gridLayoutVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => (
|
||||
<motion.div key={tab.id} layout variants={itemVariants} className="aspect-[1.5/1]">
|
||||
<TabTile
|
||||
tab={tab}
|
||||
onClick={() => handleTabClick(tab.id as TabType)}
|
||||
isActive={activeTab === tab.id}
|
||||
hasUpdate={getTabUpdateStatus(tab.id)}
|
||||
statusMessage={getStatusMessage(tab.id)}
|
||||
description={TAB_DESCRIPTIONS[tab.id]}
|
||||
isLoading={loadingTab === tab.id}
|
||||
className="h-full relative"
|
||||
></TabTile>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</RadixDialog.Content>
|
||||
</div>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user