diff --git a/app/components/settings/ControlPanel.tsx b/app/components/settings/ControlPanel.tsx new file mode 100644 index 00000000..b452e438 --- /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 d5c0714b..44bc04b8 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 f1523de0..27e85937 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' ? ( -