From 999d87b1e8ce8e3338a0e1d35d28b2d9a111b4d7 Mon Sep 17 00:00:00 2001 From: Stijnus <72551117+Stijnus@users.noreply.github.com> Date: Sat, 1 Feb 2025 18:01:34 +0100 Subject: [PATCH] beta New control panel # Tab Management System Implementation ## What's Been Implemented 1. Complete Tab Management System with: - Drag and drop functionality for reordering tabs - Visual feedback during drag operations - Smooth animations and transitions - Dark mode support - Search functionality for tabs - Reset to defaults option 2. Developer Mode Features: - Shows ALL available tabs in developer mode - Maintains tab order across modes - Proper visibility toggles - Automatic inclusion of developer-specific tabs 3. User Mode Features: - Shows only user-configured tabs - Maintains separate tab configurations - Proper visibility management ## Key Components - `TabManagement.tsx`: Main management interface - `ControlPanel.tsx`: Main panel with tab display - Integration with tab configuration store - Proper type definitions and interfaces ## Technical Features - React DnD for drag and drop - Framer Motion for animations - TypeScript for type safety - UnoCSS for styling - Toast notifications for user feedback ## Next Steps 1. Testing: - Test tab visibility in both modes - Verify drag and drop persistence - Check dark mode compatibility - Verify search functionality - Test reset functionality 2. Potential Improvements: - Add tab grouping functionality - Implement tab pinning - Add keyboard shortcuts - Improve accessibility - Add tab descriptions - Add tab icons customization 3. Documentation: - Add inline code comments - Create user documentation - Document API interfaces - Add setup instructions 4. Future Features: - Tab export/import - Custom tab creation - Tab templates - User preferences sync - Tab statistics ## Known Issues to Address 1. Ensure all tabs are visible in developer mode 2. Improve drag and drop performance 3. Better state persistence 4. Enhanced error handling 5. Improved type safety ## Usage Instructions 1. Switch to developer mode to see all available tabs 2. Use drag and drop to reorder tabs 3. Toggle visibility using switches 4. Use search to filter tabs 5. Reset to defaults if needed ## Technical Debt 1. Refactor tab configuration store 2. Improve type definitions 3. Add proper error boundaries 4. Implement proper loading states 5. Add comprehensive testing ## Security Considerations 1. Validate tab configurations 2. Sanitize user input 3. Implement proper access control 4. Add audit logging 5. Secure state management --- app/components/settings/ControlPanel.tsx | 607 ++++++++++++++++++ .../settings/developer/DeveloperWindow.tsx | 73 ++- .../settings/developer/TabManagement.tsx | 431 +++++-------- app/components/settings/settings.types.ts | 42 +- app/components/settings/user/UsersWindow.tsx | 125 ++-- app/lib/stores/settings.ts | 129 ++-- app/routes/_index.tsx | 14 + package.json | 2 + pnpm-lock.yaml | 132 +++- 9 files changed, 1124 insertions(+), 431 deletions(-) create mode 100644 app/components/settings/ControlPanel.tsx diff --git a/app/components/settings/ControlPanel.tsx b/app/components/settings/ControlPanel.tsx new file mode 100644 index 0000000..b452e43 --- /dev/null +++ b/app/components/settings/ControlPanel.tsx @@ -0,0 +1,607 @@ +import { useState, useEffect, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useStore } from '@nanostores/react'; +import { Switch } from '@radix-ui/react-switch'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { classNames } from '~/utils/classNames'; +import { TabManagement } from './developer/TabManagement'; +import { TabTile } from './shared/TabTile'; +import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck'; +import { useFeatures } from '~/lib/hooks/useFeatures'; +import { useNotifications } from '~/lib/hooks/useNotifications'; +import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus'; +import { useDebugStatus } from '~/lib/hooks/useDebugStatus'; +import { tabConfigurationStore, developerModeStore, setDeveloperMode } from '~/lib/stores/settings'; +import type { TabType, TabVisibilityConfig } from './settings.types'; +import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './settings.types'; +import { resetTabConfiguration } from '~/lib/stores/settings'; +import { DialogTitle } from '~/components/ui/Dialog'; +import { useDrag, useDrop } from 'react-dnd'; + +// Import all tab components +import ProfileTab from './profile/ProfileTab'; +import SettingsTab from './settings/SettingsTab'; +import NotificationsTab from './notifications/NotificationsTab'; +import FeaturesTab from './features/FeaturesTab'; +import DataTab from './data/DataTab'; +import DebugTab from './debug/DebugTab'; +import { EventLogsTab } from './event-logs/EventLogsTab'; +import UpdateTab from './update/UpdateTab'; +import ConnectionsTab from './connections/ConnectionsTab'; +import CloudProvidersTab from './providers/CloudProvidersTab'; +import ServiceStatusTab from './providers/ServiceStatusTab'; +import LocalProvidersTab from './providers/LocalProvidersTab'; +import TaskManagerTab from './task-manager/TaskManagerTab'; + +interface ControlPanelProps { + open: boolean; + onClose: () => void; +} + +interface TabWithDevType extends TabVisibilityConfig { + isExtraDevTab?: boolean; +} + +const TAB_DESCRIPTIONS: Record = { + profile: 'Manage your profile and account settings', + settings: 'Configure application preferences', + notifications: 'View and manage your notifications', + features: 'Explore new and upcoming features', + data: 'Manage your data and storage', + 'cloud-providers': 'Configure cloud AI providers and models', + 'local-providers': 'Configure local AI providers and models', + 'service-status': 'Monitor cloud LLM service status', + connection: 'Check connection status and settings', + debug: 'Debug tools and system information', + 'event-logs': 'View system events and logs', + update: 'Check for updates and release notes', + 'task-manager': 'Monitor system resources and processes', +}; + +// Add DraggableTabTile component before the ControlPanel component +const DraggableTabTile = ({ + tab, + index, + moveTab, + ...props +}: { + tab: TabWithDevType; + index: number; + moveTab: (dragIndex: number, hoverIndex: number) => void; + onClick: () => void; + isActive: boolean; + hasUpdate: boolean; + statusMessage: string; + description: string; + isLoading?: boolean; +}) => { + const [{ isDragging }, drag] = useDrag({ + type: 'tab', + item: { index, id: tab.id }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + const [{ isOver, canDrop }, drop] = useDrop({ + accept: 'tab', + hover: (item: { index: number; id: string }, monitor) => { + if (!monitor.isOver({ shallow: true })) { + return; + } + + if (item.id === tab.id) { + return; + } + + if (item.index === index) { + return; + } + + // Only move when hovering over the middle section + const hoverBoundingRect = monitor.getSourceClientOffset(); + const clientOffset = monitor.getClientOffset(); + + if (!hoverBoundingRect || !clientOffset) { + return; + } + + const hoverMiddleX = hoverBoundingRect.x + 150; // Half of typical card width + const hoverClientX = clientOffset.x; + + // Only perform the move when the mouse has crossed half of the items width + if (item.index < index && hoverClientX < hoverMiddleX) { + return; + } + + if (item.index > index && hoverClientX > hoverMiddleX) { + return; + } + + moveTab(item.index, index); + item.index = index; + }, + collect: (monitor) => ({ + isOver: monitor.isOver({ shallow: true }), + canDrop: monitor.canDrop(), + }), + }); + + const dropIndicatorClasses = classNames('rounded-xl border-2 border-transparent transition-all duration-200', { + 'ring-2 ring-purple-500 ring-opacity-50 bg-purple-50 dark:bg-purple-900/20': isOver, + 'hover:ring-2 hover:ring-purple-500/30': canDrop && !isOver, + }); + + return ( + drag(drop(node))} + style={{ + opacity: isDragging ? 0.5 : 1, + cursor: 'move', + position: 'relative', + zIndex: isDragging ? 100 : isOver ? 50 : 1, + }} + animate={{ + scale: isDragging ? 1.02 : isOver ? 1.05 : 1, + boxShadow: isDragging + ? '0 8px 24px rgba(0, 0, 0, 0.15)' + : isOver + ? '0 4px 12px rgba(147, 51, 234, 0.3)' + : '0 0 0 rgba(0, 0, 0, 0)', + borderColor: isOver ? 'rgb(147, 51, 234)' : isDragging ? 'rgba(147, 51, 234, 0.5)' : 'transparent', + y: isOver ? -2 : 0, + }} + transition={{ + type: 'spring', + stiffness: 500, + damping: 30, + mass: 0.8, + }} + className={dropIndicatorClasses} + > + + {isOver && ( + +
+
+ + )} + + ); +}; + +export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { + // State + const [activeTab, setActiveTab] = useState(null); + const [loadingTab, setLoadingTab] = useState(null); + const [showTabManagement, setShowTabManagement] = useState(false); + const [profile, setProfile] = useState({ avatar: null, notifications: true }); + + // Store values + const tabConfiguration = useStore(tabConfigurationStore); + const developerMode = useStore(developerModeStore); + + // Status hooks + const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck(); + const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures(); + const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications(); + const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus(); + const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus(); + + // Initialize profile from localStorage on mount + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const saved = localStorage.getItem('bolt_user_profile'); + + if (saved) { + try { + const parsedProfile = JSON.parse(saved); + setProfile(parsedProfile); + } catch (error) { + console.warn('Failed to parse profile from localStorage:', error); + } + } + }, []); + + // Add visibleTabs logic using useMemo + const visibleTabs = useMemo(() => { + if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) { + console.warn('Invalid tab configuration, resetting to defaults'); + resetTabConfiguration(); + + return []; + } + + // In developer mode, show ALL tabs without restrictions + if (developerMode) { + // Combine all unique tabs from both user and developer configurations + const allTabs = new Set([ + ...DEFAULT_TAB_CONFIG.map((tab) => tab.id), + ...tabConfiguration.userTabs.map((tab) => tab.id), + ...(tabConfiguration.developerTabs || []).map((tab) => tab.id), + ]); + + // Create a complete tab list with all tabs visible + const devTabs = Array.from(allTabs).map((tabId) => { + // Try to find existing configuration for this tab + const existingTab = + tabConfiguration.developerTabs?.find((t) => t.id === tabId) || + tabConfiguration.userTabs?.find((t) => t.id === tabId) || + DEFAULT_TAB_CONFIG.find((t) => t.id === tabId); + + return { + id: tabId, + visible: true, + window: 'developer' as const, + order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId), + }; + }); + + return devTabs.sort((a, b) => a.order - b.order); + } + + // In user mode, only show visible user tabs + return tabConfiguration.userTabs + .filter((tab) => { + if (!tab || typeof tab.id !== 'string') { + console.warn('Invalid tab entry:', tab); + return false; + } + + // Hide notifications tab if notifications are disabled + if (tab.id === 'notifications' && !profile.notifications) { + return false; + } + + // Only show tabs that are explicitly visible and assigned to the user window + return tab.visible && tab.window === 'user'; + }) + .sort((a, b) => a.order - b.order); + }, [tabConfiguration, profile.notifications, developerMode]); + + // Add moveTab handler + const moveTab = (dragIndex: number, hoverIndex: number) => { + const newTabs = [...visibleTabs]; + const dragTab = newTabs[dragIndex]; + newTabs.splice(dragIndex, 1); + newTabs.splice(hoverIndex, 0, dragTab); + + // Update the order of the tabs + const updatedTabs = newTabs.map((tab, index) => ({ + ...tab, + order: index, + window: 'developer' as const, + visible: true, + })); + + // Update the tab configuration store directly + if (developerMode) { + // In developer mode, update developerTabs while preserving configuration + tabConfigurationStore.set({ + ...tabConfiguration, + developerTabs: updatedTabs, + }); + } else { + // In user mode, update userTabs + tabConfigurationStore.set({ + ...tabConfiguration, + userTabs: updatedTabs.map((tab) => ({ ...tab, window: 'user' as const })), + }); + } + }; + + // Handlers + const handleBack = () => { + if (showTabManagement) { + setShowTabManagement(false); + } else if (activeTab) { + setActiveTab(null); + } + }; + + const handleDeveloperModeChange = (checked: boolean) => { + console.log('Developer mode changed:', checked); + setDeveloperMode(checked); + }; + + // Add effect to log developer mode changes + useEffect(() => { + console.log('Current developer mode:', developerMode); + }, [developerMode]); + + const getTabComponent = () => { + switch (activeTab) { + case 'profile': + return ; + case 'settings': + return ; + case 'notifications': + return ; + case 'features': + return ; + case 'data': + return ; + case 'cloud-providers': + return ; + case 'local-providers': + return ; + case 'connection': + return ; + case 'debug': + return ; + case 'event-logs': + return ; + case 'update': + return ; + case 'task-manager': + return ; + case 'service-status': + return ; + default: + return null; + } + }; + + const getTabUpdateStatus = (tabId: TabType): boolean => { + switch (tabId) { + case 'update': + return hasUpdate; + case 'features': + return hasNewFeatures; + case 'notifications': + return hasUnreadNotifications; + case 'connection': + return hasConnectionIssues; + case 'debug': + return hasActiveWarnings; + default: + return false; + } + }; + + const getStatusMessage = (tabId: TabType): string => { + switch (tabId) { + case 'update': + return `New update available (v${currentVersion})`; + case 'features': + return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`; + case 'notifications': + return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`; + case 'connection': + return currentIssue === 'disconnected' + ? 'Connection lost' + : currentIssue === 'high-latency' + ? 'High latency detected' + : 'Connection issues detected'; + 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 'update': + acknowledgeUpdate(); + break; + case 'features': + acknowledgeAllFeatures(); + break; + case 'notifications': + markAllAsRead(); + break; + case 'connection': + acknowledgeIssue(); + break; + case 'debug': + acknowledgeAllIssues(); + break; + } + + // Clear loading state after a delay + setTimeout(() => setLoadingTab(null), 500); + }; + + return ( + + + +
+ + + + + + + {/* Header */} +
+
+ {activeTab || showTabManagement ? ( + + ) : ( + + )} + + {showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'} + +
+ +
+ {/* Only show Manage Tabs button in developer mode */} + {!activeTab && !showTabManagement && developerMode && ( + setShowTabManagement(true)} + className="flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > +
+ + Manage Tabs + + + )} + +
+ + Toggle developer mode + + + +
+ + +
+
+ + {/* Content */} +
+ + {showTabManagement ? ( + + ) : activeTab ? ( + getTabComponent() + ) : ( + + + {visibleTabs.map((tab: TabWithDevType, index: number) => ( + + handleTabClick(tab.id)} + isActive={activeTab === tab.id} + hasUpdate={getTabUpdateStatus(tab.id)} + statusMessage={getStatusMessage(tab.id)} + description={TAB_DESCRIPTIONS[tab.id]} + isLoading={loadingTab === tab.id} + /> + + ))} + + + )} + +
+ + +
+ + + + ); +}; diff --git a/app/components/settings/developer/DeveloperWindow.tsx b/app/components/settings/developer/DeveloperWindow.tsx index d5c0714..44bc04b 100644 --- a/app/components/settings/developer/DeveloperWindow.tsx +++ b/app/components/settings/developer/DeveloperWindow.tsx @@ -1,5 +1,5 @@ import * as RadixDialog from '@radix-ui/react-dialog'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { useState, useEffect, useMemo } from 'react'; import { classNames } from '~/utils/classNames'; import { TabManagement } from './TabManagement'; @@ -481,14 +481,9 @@ export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => { 'border border-[#E5E5E5] dark:border-[#1A1A1A]', 'flex flex-col overflow-hidden', )} - initial={{ opacity: 0, scale: 0.95, y: 20 }} - animate={{ - opacity: developerMode ? 1 : 0, - scale: developerMode ? 1 : 0.95, - y: developerMode ? 0 : 20, - }} - exit={{ opacity: 0, scale: 0.95, y: 20 }} - transition={{ duration: 0.2 }} + initial={{ opacity: 1 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.15 }} > {/* Header */}
@@ -592,28 +587,54 @@ export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => { 'touch-auto', )} > - + {showTabManagement ? ( ) : activeTab ? ( getTabComponent() ) : ( -
- {visibleDeveloperTabs.map((tab: TabVisibilityConfig, index: number) => ( - handleTabClick(tab.id)} - isActive={activeTab === tab.id} - hasUpdate={getTabUpdateStatus(tab.id)} - statusMessage={getStatusMessage(tab.id)} - description={TAB_DESCRIPTIONS[tab.id]} - isLoading={loadingTab === tab.id} - /> - ))} -
+ + + {visibleDeveloperTabs.map((tab: TabVisibilityConfig, index: number) => ( + + handleTabClick(tab.id)} + isActive={activeTab === tab.id} + hasUpdate={getTabUpdateStatus(tab.id)} + statusMessage={getStatusMessage(tab.id)} + description={TAB_DESCRIPTIONS[tab.id]} + isLoading={loadingTab === tab.id} + /> + + ))} + + )}
diff --git a/app/components/settings/developer/TabManagement.tsx b/app/components/settings/developer/TabManagement.tsx index f1523de..27e8593 100644 --- a/app/components/settings/developer/TabManagement.tsx +++ b/app/components/settings/developer/TabManagement.tsx @@ -1,9 +1,16 @@ -import { motion } from 'framer-motion'; -import { useState } from 'react'; -import { classNames } from '~/utils/classNames'; -import { tabConfigurationStore, updateTabConfiguration, resetTabConfiguration } from '~/lib/stores/settings'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useState, useMemo } from 'react'; import { useStore } from '@nanostores/react'; -import { TAB_LABELS, type TabType, type TabVisibilityConfig } from '~/components/settings/settings.types'; +import { DndProvider, useDrag, useDrop } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { classNames } from '~/utils/classNames'; +import { tabConfigurationStore, resetTabConfiguration } from '~/lib/stores/settings'; +import { + TAB_LABELS, + DEFAULT_TAB_CONFIG, + type TabType, + type TabVisibilityConfig, +} from '~/components/settings/settings.types'; import { toast } from 'react-toastify'; // Define icons for each tab type @@ -23,152 +30,88 @@ const TAB_ICONS: Record = { 'service-status': 'i-ph:heartbeat-fill', }; -interface TabGroupProps { - title: string; - description?: string; - tabs: TabVisibilityConfig[]; - onVisibilityChange: (tabId: TabType, enabled: boolean) => void; - targetWindow: 'user' | 'developer'; - standardTabs: TabType[]; +interface DraggableTabProps { + tab: TabVisibilityConfig; + index: number; + moveTab: (dragIndex: number, hoverIndex: number) => void; + onVisibilityChange: (enabled: boolean) => void; } -const TabGroup = ({ title, description, tabs, onVisibilityChange, targetWindow }: TabGroupProps) => { - // Split tabs into visible and hidden - const visibleTabs = tabs.filter((tab) => tab.visible).sort((a, b) => (a.order || 0) - (b.order || 0)); - const hiddenTabs = tabs.filter((tab) => !tab.visible).sort((a, b) => (a.order || 0) - (b.order || 0)); +const DraggableTab = ({ tab, index, moveTab, onVisibilityChange }: DraggableTabProps) => { + const [{ isDragging }, drag] = useDrag({ + type: 'tab-management', + item: { index, id: tab.id }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + const [{ isOver }, drop] = useDrop({ + accept: 'tab-management', + hover: (item: { index: number; id: string }, monitor) => { + if (!monitor.isOver({ shallow: true })) { + return; + } + + if (item.id === tab.id) { + return; + } + + if (item.index === index) { + return; + } + + moveTab(item.index, index); + item.index = index; + }, + collect: (monitor) => ({ + isOver: monitor.isOver({ shallow: true }), + }), + }); return ( -
-
-

- - {title} -

- {description &&

{description}

} + drag(drop(node))} + layout + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -20 }} + style={{ + opacity: isDragging ? 0.5 : 1, + cursor: 'move', + }} + className={classNames( + 'group relative flex items-center justify-between rounded-lg border px-4 py-3 transition-all', + isOver + ? 'border-purple-500 bg-purple-50/50 dark:border-purple-500/50 dark:bg-purple-500/10' + : 'border-gray-200 bg-white hover:border-purple-200 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-purple-500/30', + )} + > +
+
+ {TAB_LABELS[tab.id]}
- -
- - {visibleTabs.map((tab) => ( - -
-
- - {TAB_LABELS[tab.id]} - - {tab.id === 'profile' && targetWindow === 'user' && ( - - Standard - - )} -
-
- {targetWindow === 'user' ? ( -