diff --git a/app/components/@settings/core/AvatarDropdown.tsx b/app/components/@settings/core/AvatarDropdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6adfd31d3cb51f850d981d5efe8110cc0d0f223b --- /dev/null +++ b/app/components/@settings/core/AvatarDropdown.tsx @@ -0,0 +1,158 @@ +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { motion } from 'framer-motion'; +import { useStore } from '@nanostores/react'; +import { classNames } from '~/utils/classNames'; +import { profileStore } from '~/lib/stores/profile'; +import type { TabType, Profile } from './types'; + +const BetaLabel = () => ( + + BETA + +); + +interface AvatarDropdownProps { + onSelectTab: (tab: TabType) => void; +} + +export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => { + const profile = useStore(profileStore) as Profile; + + return ( + + + + {profile?.avatar ? ( + {profile?.username + ) : ( +
+
+
+ )} + + + + + +
+
+ {profile?.avatar ? ( + {profile?.username + ) : ( +
+ ? +
+ )} +
+
+
+ {profile?.username || 'Guest User'} +
+ {profile?.bio &&
{profile.bio}
} +
+
+ + onSelectTab('profile')} + > +
+ Edit Profile + + + onSelectTab('settings')} + > +
+ Settings + + +
+ + onSelectTab('task-manager')} + > +
+ Task Manager + + + + onSelectTab('service-status')} + > +
+ Service Status + + + + + + ); +}; diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0d90975cefabf354911bc14cc69ce0d2197404d8 --- /dev/null +++ b/app/components/@settings/core/ControlPanel.tsx @@ -0,0 +1,555 @@ +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 { classNames } from '~/utils/classNames'; +import { TabManagement } from '~/components/@settings/shared/components/TabManagement'; +import { TabTile } from '~/components/@settings/shared/components/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, + resetTabConfiguration, +} from '~/lib/stores/settings'; +import { profileStore } from '~/lib/stores/profile'; +import type { TabType, TabVisibilityConfig, Profile } from './types'; +import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants'; +import { DialogTitle } from '~/components/ui/Dialog'; +import { AvatarDropdown } from './AvatarDropdown'; +import BackgroundRays from '~/components/ui/BackgroundRays'; + +// Import all tab components +import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab'; +import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab'; +import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab'; +import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab'; +import DataTab from '~/components/@settings/tabs/data/DataTab'; +import DebugTab from '~/components/@settings/tabs/debug/DebugTab'; +import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab'; +import UpdateTab from '~/components/@settings/tabs/update/UpdateTab'; +import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab'; +import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab'; +import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab'; +import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab'; +import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab'; + +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; +} + +interface AnimatedSwitchProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + id: string; + label: string; +} + +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', + 'tab-management': 'Configure visible tabs and their order', +}; + +// Beta status for experimental features +const BETA_TABS = new Set(['task-manager', 'service-status', 'update', 'local-providers']); + +const BetaLabel = () => ( +
+ BETA +
+); + +const AnimatedSwitch = ({ checked, onCheckedChange, id, label }: AnimatedSwitchProps) => { + return ( +
+ + + + + Toggle {label} + +
+ +
+
+ ); +}; + +export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { + // State + const [activeTab, setActiveTab] = useState(null); + const [loadingTab, setLoadingTab] = useState(null); + const [showTabManagement, setShowTabManagement] = useState(false); + + // Store values + const tabConfiguration = useStore(tabConfigurationStore); + const developerMode = useStore(developerModeStore); + const profile = useStore(profileStore) as Profile; + + // 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(); + + // 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)) { + console.warn('Invalid tab configuration, resetting to defaults'); + resetTabConfiguration(); + + return []; + } + + const notificationsDisabled = profile?.preferences?.notifications === false; + + // In developer mode, show ALL tabs without restrictions + if (developerMode) { + const seenTabs = new Set(); + 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) => processTab(tab as BaseTabConfig)); + tabConfiguration.userTabs.forEach((tab) => processTab(tab as BaseTabConfig)); + DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig)); + + // Add Tab Management tile + devTabs.push({ + id: 'tab-management' as TabType, + visible: true, + window: 'developer', + order: devTabs.length, + isExtraDevTab: true, + }); + + return devTabs.sort((a, b) => a.order - b.order); + } + + // Optimize user mode tab filtering + return tabConfiguration.userTabs + .filter((tab) => { + if (!tab?.id) { + return false; + } + + if (tab.id === 'notifications' && notificationsDisabled) { + return false; + } + + return tab.visible && tab.window === 'user'; + }) + .sort((a, b) => a.order - b.order); + }, [tabConfiguration, developerMode, 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 = { + 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); + setShowTabManagement(false); + } else { + // When opening, set to null to show the main view + setActiveTab(null); + } + }, [open]); + + // Handle closing + const handleClose = () => { + setActiveTab(null); + setLoadingTab(null); + setShowTabManagement(false); + onClose(); + }; + + // 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 = (tabId: TabType | 'tab-management') => { + if (tabId === 'tab-management') { + return ; + } + + switch (tabId) { + 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); + setShowTabManagement(false); + + // 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'} + +
+ +
+ {/* Mode Toggle */} +
+ +
+ + {/* Avatar and Dropdown */} +
+ +
+ + {/* Close Button */} + +
+
+ + {/* Content */} +
+ + {showTabManagement ? ( + + ) : activeTab ? ( + getTabComponent(activeTab) + ) : ( + + + {(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => ( + + 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" + > + {BETA_TABS.has(tab.id) && } + + + ))} + + + )} + +
+
+
+
+
+
+
+ ); +}; diff --git a/app/components/@settings/core/constants.ts b/app/components/@settings/core/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff72a2746f8b22c166a6eff497d3a237462fc075 --- /dev/null +++ b/app/components/@settings/core/constants.ts @@ -0,0 +1,88 @@ +import type { TabType } from './types'; + +export const TAB_ICONS: Record = { + profile: 'i-ph:user-circle-fill', + settings: 'i-ph:gear-six-fill', + notifications: 'i-ph:bell-fill', + features: 'i-ph:star-fill', + data: 'i-ph:database-fill', + 'cloud-providers': 'i-ph:cloud-fill', + 'local-providers': 'i-ph:desktop-fill', + 'service-status': 'i-ph:activity-bold', + connection: 'i-ph:wifi-high-fill', + debug: 'i-ph:bug-fill', + 'event-logs': 'i-ph:list-bullets-fill', + update: 'i-ph:arrow-clockwise-fill', + 'task-manager': 'i-ph:chart-line-fill', + 'tab-management': 'i-ph:squares-four-fill', +}; + +export const TAB_LABELS: Record = { + profile: 'Profile', + settings: 'Settings', + notifications: 'Notifications', + features: 'Features', + data: 'Data Management', + 'cloud-providers': 'Cloud Providers', + 'local-providers': 'Local Providers', + 'service-status': 'Service Status', + connection: 'Connection', + debug: 'Debug', + 'event-logs': 'Event Logs', + update: 'Updates', + 'task-manager': 'Task Manager', + 'tab-management': 'Tab Management', +}; + +export 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', + 'tab-management': 'Configure visible tabs and their order', +}; + +export const DEFAULT_TAB_CONFIG = [ + // User Window Tabs (Always visible by default) + { id: 'features', visible: true, window: 'user' as const, order: 0 }, + { id: 'data', visible: true, window: 'user' as const, order: 1 }, + { id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 }, + { id: 'local-providers', visible: true, window: 'user' as const, order: 3 }, + { id: 'connection', visible: true, window: 'user' as const, order: 4 }, + { id: 'notifications', visible: true, window: 'user' as const, order: 5 }, + { id: 'event-logs', visible: true, window: 'user' as const, order: 6 }, + + // User Window Tabs (In dropdown, initially hidden) + { id: 'profile', visible: false, window: 'user' as const, order: 7 }, + { id: 'settings', visible: false, window: 'user' as const, order: 8 }, + { id: 'task-manager', visible: false, window: 'user' as const, order: 9 }, + { id: 'service-status', visible: false, window: 'user' as const, order: 10 }, + + // User Window Tabs (Hidden, controlled by TaskManagerTab) + { id: 'debug', visible: false, window: 'user' as const, order: 11 }, + { id: 'update', visible: false, window: 'user' as const, order: 12 }, + + // Developer Window Tabs (All visible by default) + { id: 'features', visible: true, window: 'developer' as const, order: 0 }, + { id: 'data', visible: true, window: 'developer' as const, order: 1 }, + { id: 'cloud-providers', visible: true, window: 'developer' as const, order: 2 }, + { id: 'local-providers', visible: true, window: 'developer' as const, order: 3 }, + { id: 'connection', visible: true, window: 'developer' as const, order: 4 }, + { id: 'notifications', visible: true, window: 'developer' as const, order: 5 }, + { id: 'event-logs', visible: true, window: 'developer' as const, order: 6 }, + { id: 'profile', visible: true, window: 'developer' as const, order: 7 }, + { id: 'settings', visible: true, window: 'developer' as const, order: 8 }, + { id: 'task-manager', visible: true, window: 'developer' as const, order: 9 }, + { id: 'service-status', visible: true, window: 'developer' as const, order: 10 }, + { id: 'debug', visible: true, window: 'developer' as const, order: 11 }, + { id: 'update', visible: true, window: 'developer' as const, order: 12 }, +]; diff --git a/app/components/@settings/core/types.ts b/app/components/@settings/core/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..97d4d3606b9b74517192d2351fb09a3e484927ef --- /dev/null +++ b/app/components/@settings/core/types.ts @@ -0,0 +1,114 @@ +import type { ReactNode } from 'react'; + +export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences'; + +export type TabType = + | 'profile' + | 'settings' + | 'notifications' + | 'features' + | 'data' + | 'cloud-providers' + | 'local-providers' + | 'service-status' + | 'connection' + | 'debug' + | 'event-logs' + | 'update' + | 'task-manager' + | 'tab-management'; + +export type WindowType = 'user' | 'developer'; + +export interface UserProfile { + nickname: any; + name: string; + email: string; + avatar?: string; + theme: 'light' | 'dark' | 'system'; + notifications: boolean; + password?: string; + bio?: string; + language: string; + timezone: string; +} + +export interface SettingItem { + id: TabType; + label: string; + icon: string; + category: SettingCategory; + description?: string; + component: () => ReactNode; + badge?: string; + keywords?: string[]; +} + +export interface TabVisibilityConfig { + id: TabType; + visible: boolean; + window: WindowType; + order: number; + isExtraDevTab?: boolean; + locked?: boolean; +} + +export interface DevTabConfig extends TabVisibilityConfig { + window: 'developer'; +} + +export interface UserTabConfig extends TabVisibilityConfig { + window: 'user'; +} + +export interface TabWindowConfig { + userTabs: UserTabConfig[]; + developerTabs: DevTabConfig[]; +} + +export const TAB_LABELS: Record = { + profile: 'Profile', + settings: 'Settings', + notifications: 'Notifications', + features: 'Features', + data: 'Data Management', + 'cloud-providers': 'Cloud Providers', + 'local-providers': 'Local Providers', + 'service-status': 'Service Status', + connection: 'Connections', + debug: 'Debug', + 'event-logs': 'Event Logs', + update: 'Updates', + 'task-manager': 'Task Manager', + 'tab-management': 'Tab Management', +}; + +export const categoryLabels: Record = { + profile: 'Profile & Account', + file_sharing: 'File Sharing', + connectivity: 'Connectivity', + system: 'System', + services: 'Services', + preferences: 'Preferences', +}; + +export const categoryIcons: Record = { + profile: 'i-ph:user-circle', + file_sharing: 'i-ph:folder-simple', + connectivity: 'i-ph:wifi-high', + system: 'i-ph:gear', + services: 'i-ph:cube', + preferences: 'i-ph:sliders', +}; + +export interface Profile { + username?: string; + bio?: string; + avatar?: string; + preferences?: { + notifications?: boolean; + theme?: 'light' | 'dark' | 'system'; + language?: string; + timezone?: string; + }; +} diff --git a/app/components/@settings/index.ts b/app/components/@settings/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..862c33ef773941e63e4b84416be7f96be8a91eda --- /dev/null +++ b/app/components/@settings/index.ts @@ -0,0 +1,14 @@ +// Core exports +export { ControlPanel } from './core/ControlPanel'; +export type { TabType, TabVisibilityConfig } from './core/types'; + +// Constants +export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constants'; + +// Shared components +export { TabTile } from './shared/components/TabTile'; +export { TabManagement } from './shared/components/TabManagement'; + +// Utils +export { getVisibleTabs, reorderTabs, resetToDefaultConfig } from './utils/tab-helpers'; +export * from './utils/animations'; diff --git a/app/components/@settings/shared/components/DraggableTabList.tsx b/app/components/@settings/shared/components/DraggableTabList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a8681835dc3812a6a7e1464133fbab576e7bcfa6 --- /dev/null +++ b/app/components/@settings/shared/components/DraggableTabList.tsx @@ -0,0 +1,163 @@ +import { useDrag, useDrop } from 'react-dnd'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; +import type { TabVisibilityConfig } from '~/components/@settings/core/types'; +import { TAB_LABELS } from '~/components/@settings/core/types'; +import { Switch } from '~/components/ui/Switch'; + +interface DraggableTabListProps { + tabs: TabVisibilityConfig[]; + onReorder: (tabs: TabVisibilityConfig[]) => void; + onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void; + onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void; + showControls?: boolean; +} + +interface DraggableTabItemProps { + tab: TabVisibilityConfig; + index: number; + moveTab: (dragIndex: number, hoverIndex: number) => void; + showControls?: boolean; + onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void; + onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void; +} + +interface DragItem { + type: string; + index: number; + id: string; +} + +const DraggableTabItem = ({ + tab, + index, + moveTab, + showControls, + onWindowChange, + onVisibilityChange, +}: DraggableTabItemProps) => { + const [{ isDragging }, dragRef] = useDrag({ + type: 'tab', + item: { type: 'tab', index, id: tab.id }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + const [, dropRef] = useDrop({ + accept: 'tab', + hover: (item: DragItem, monitor) => { + if (!monitor.isOver({ shallow: true })) { + return; + } + + if (item.index === index) { + return; + } + + if (item.id === tab.id) { + return; + } + + moveTab(item.index, index); + item.index = index; + }, + }); + + const ref = (node: HTMLDivElement | null) => { + dragRef(node); + dropRef(node); + }; + + return ( + +
+
+
+
+
+
{TAB_LABELS[tab.id]}
+ {showControls && ( +
+ Order: {tab.order}, Window: {tab.window} +
+ )} +
+
+ {showControls && !tab.locked && ( +
+
+ onVisibilityChange?.(tab, checked)} + className="data-[state=checked]:bg-purple-500" + aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`} + /> + +
+
+ + onWindowChange?.(tab, checked ? 'developer' : 'user')} + className="data-[state=checked]:bg-purple-500" + aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`} + /> + +
+
+ )} + + ); +}; + +export const DraggableTabList = ({ + tabs, + onReorder, + onWindowChange, + onVisibilityChange, + showControls = false, +}: DraggableTabListProps) => { + const moveTab = (dragIndex: number, hoverIndex: number) => { + const items = Array.from(tabs); + const [reorderedItem] = items.splice(dragIndex, 1); + items.splice(hoverIndex, 0, reorderedItem); + + // Update order numbers based on position + const reorderedTabs = items.map((tab, index) => ({ + ...tab, + order: index + 1, + })); + + onReorder(reorderedTabs); + }; + + return ( +
+ {tabs.map((tab, index) => ( + + ))} +
+ ); +}; diff --git a/app/components/@settings/shared/components/TabManagement.tsx b/app/components/@settings/shared/components/TabManagement.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9ae160171923006723cb824b9416d3a931c1ed20 --- /dev/null +++ b/app/components/@settings/shared/components/TabManagement.tsx @@ -0,0 +1,380 @@ +import { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { useStore } from '@nanostores/react'; +import { Switch } from '~/components/ui/Switch'; +import { classNames } from '~/utils/classNames'; +import { tabConfigurationStore } from '~/lib/stores/settings'; +import { TAB_LABELS } from '~/components/@settings/core/constants'; +import type { TabType } from '~/components/@settings/core/types'; +import { toast } from 'react-toastify'; +import { TbLayoutGrid } from 'react-icons/tb'; +import { useSettingsStore } from '~/lib/stores/settings'; + +// Define tab icons mapping +const TAB_ICONS: Record = { + profile: 'i-ph:user-circle-fill', + settings: 'i-ph:gear-six-fill', + notifications: 'i-ph:bell-fill', + features: 'i-ph:star-fill', + data: 'i-ph:database-fill', + 'cloud-providers': 'i-ph:cloud-fill', + 'local-providers': 'i-ph:desktop-fill', + 'service-status': 'i-ph:activity-fill', + connection: 'i-ph:wifi-high-fill', + debug: 'i-ph:bug-fill', + 'event-logs': 'i-ph:list-bullets-fill', + update: 'i-ph:arrow-clockwise-fill', + 'task-manager': 'i-ph:chart-line-fill', + 'tab-management': 'i-ph:squares-four-fill', +}; + +// Define which tabs are default in user mode +const DEFAULT_USER_TABS: TabType[] = [ + 'features', + 'data', + 'cloud-providers', + 'local-providers', + 'connection', + 'notifications', + 'event-logs', +]; + +// Define which tabs can be added to user mode +const OPTIONAL_USER_TABS: TabType[] = ['profile', 'settings', 'task-manager', 'service-status', 'debug', 'update']; + +// All available tabs for user mode +const ALL_USER_TABS = [...DEFAULT_USER_TABS, ...OPTIONAL_USER_TABS]; + +// Define which tabs are beta +const BETA_TABS = new Set(['task-manager', 'service-status', 'update', 'local-providers']); + +// Beta label component +const BetaLabel = () => ( + BETA +); + +export const TabManagement = () => { + const [searchQuery, setSearchQuery] = useState(''); + const tabConfiguration = useStore(tabConfigurationStore); + const { setSelectedTab } = useSettingsStore(); + + const handleTabVisibilityChange = (tabId: TabType, checked: boolean) => { + // Get current tab configuration + const currentTab = tabConfiguration.userTabs.find((tab) => tab.id === tabId); + + // If tab doesn't exist in configuration, create it + if (!currentTab) { + const newTab = { + id: tabId, + visible: checked, + window: 'user' as const, + order: tabConfiguration.userTabs.length, + }; + + const updatedTabs = [...tabConfiguration.userTabs, newTab]; + + tabConfigurationStore.set({ + ...tabConfiguration, + userTabs: updatedTabs, + }); + + toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`); + + return; + } + + // Check if tab can be enabled in user mode + const canBeEnabled = DEFAULT_USER_TABS.includes(tabId) || OPTIONAL_USER_TABS.includes(tabId); + + if (!canBeEnabled && checked) { + toast.error('This tab cannot be enabled in user mode'); + return; + } + + // Update tab visibility + const updatedTabs = tabConfiguration.userTabs.map((tab) => { + if (tab.id === tabId) { + return { ...tab, visible: checked }; + } + + return tab; + }); + + // Update store + tabConfigurationStore.set({ + ...tabConfiguration, + userTabs: updatedTabs, + }); + + // Show success message + toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`); + }; + + // Create a map of existing tab configurations + const tabConfigMap = new Map(tabConfiguration.userTabs.map((tab) => [tab.id, tab])); + + // Generate the complete list of tabs, including those not in the configuration + const allTabs = ALL_USER_TABS.map((tabId) => { + return ( + tabConfigMap.get(tabId) || { + id: tabId, + visible: false, + window: 'user' as const, + order: -1, + } + ); + }); + + // Filter tabs based on search query + const filteredTabs = allTabs.filter((tab) => TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase())); + + useEffect(() => { + // Reset to first tab when component unmounts + return () => { + setSelectedTab('user'); // Reset to user tab when unmounting + }; + }, [setSelectedTab]); + + return ( +
+ + {/* Header */} +
+
+
+ +
+
+

Tab Management

+

Configure visible tabs and their order

+
+
+ + {/* Search */} +
+
+
+
+ setSearchQuery(e.target.value)} + placeholder="Search tabs..." + className={classNames( + 'w-full pl-10 pr-4 py-2 rounded-lg', + 'bg-bolt-elements-background-depth-2', + 'border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary', + 'placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/30', + 'transition-all duration-200', + )} + /> +
+
+ + {/* Tab Grid */} +
+ {/* Default Section Header */} + {filteredTabs.some((tab) => DEFAULT_USER_TABS.includes(tab.id)) && ( +
+
+ Default Tabs +
+ )} + + {/* Default Tabs */} + {filteredTabs + .filter((tab) => DEFAULT_USER_TABS.includes(tab.id)) + .map((tab, index) => ( + + {/* Status Badges */} +
+ + Default + +
+ +
+ +
+
+
+ + +
+
+
+
+

+ {TAB_LABELS[tab.id]} +

+ {BETA_TABS.has(tab.id) && } +
+

+ {tab.visible ? 'Visible in user mode' : 'Hidden in user mode'} +

+
+ { + const isDisabled = + !DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id); + + if (!isDisabled) { + handleTabVisibilityChange(tab.id, checked); + } + }} + className={classNames('data-[state=checked]:bg-purple-500 ml-4', { + 'opacity-50 pointer-events-none': + !DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id), + })} + /> +
+
+
+ + + + ))} + + {/* Optional Section Header */} + {filteredTabs.some((tab) => OPTIONAL_USER_TABS.includes(tab.id)) && ( +
+
+ Optional Tabs +
+ )} + + {/* Optional Tabs */} + {filteredTabs + .filter((tab) => OPTIONAL_USER_TABS.includes(tab.id)) + .map((tab, index) => ( + + {/* Status Badges */} +
+ + Optional + +
+ +
+ +
+
+
+ + +
+
+
+
+

+ {TAB_LABELS[tab.id]} +

+ {BETA_TABS.has(tab.id) && } +
+

+ {tab.visible ? 'Visible in user mode' : 'Hidden in user mode'} +

+
+ { + const isDisabled = + !DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id); + + if (!isDisabled) { + handleTabVisibilityChange(tab.id, checked); + } + }} + className={classNames('data-[state=checked]:bg-purple-500 ml-4', { + 'opacity-50 pointer-events-none': + !DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id), + })} + /> +
+
+
+ + + + ))} +
+
+
+ ); +}; diff --git a/app/components/@settings/shared/components/TabTile.tsx b/app/components/@settings/shared/components/TabTile.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea409d690d1a6beff7abb128ce3bd0bc28d54721 --- /dev/null +++ b/app/components/@settings/shared/components/TabTile.tsx @@ -0,0 +1,135 @@ +import { motion } from 'framer-motion'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { classNames } from '~/utils/classNames'; +import type { TabVisibilityConfig } from '~/components/@settings/core/types'; +import { TAB_LABELS, TAB_ICONS } from '~/components/@settings/core/constants'; + +interface TabTileProps { + tab: TabVisibilityConfig; + onClick?: () => void; + isActive?: boolean; + hasUpdate?: boolean; + statusMessage?: string; + description?: string; + isLoading?: boolean; + className?: string; + children?: React.ReactNode; +} + +export const TabTile: React.FC = ({ + tab, + onClick, + isActive, + hasUpdate, + statusMessage, + description, + isLoading, + className, + children, +}: TabTileProps) => { + return ( + + + + + {/* Main Content */} +
+ {/* Icon */} + + + + + {/* Label and Description */} +
+

+ {TAB_LABELS[tab.id]} +

+ {description && ( +

+ {description} +

+ )} +
+
+ + {/* Update Indicator with Tooltip */} + {hasUpdate && ( + <> +
+ + + {statusMessage} + + + + + )} + + {/* Children (e.g. Beta Label) */} + {children} + + + + + ); +}; diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..450d241a912771590831f59ea9e8f05eddf411e2 --- /dev/null +++ b/app/components/@settings/tabs/connections/ConnectionsTab.tsx @@ -0,0 +1,28 @@ +import { motion } from 'framer-motion'; +import { GithubConnection } from './GithubConnection'; +import { NetlifyConnection } from './NetlifyConnection'; + +export default function ConnectionsTab() { + return ( +
+ {/* Header */} + +
+

Connection Settings

+ +

+ Manage your external service connections and integrations +

+ +
+ + +
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e2d8924f8795ee5a1f386c1ff348d77179670673 --- /dev/null +++ b/app/components/@settings/tabs/connections/GithubConnection.tsx @@ -0,0 +1,557 @@ +import React, { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; +import { logStore } from '~/lib/stores/logs'; +import { classNames } from '~/utils/classNames'; + +interface GitHubUserResponse { + login: string; + avatar_url: string; + html_url: string; + name: string; + bio: string; + public_repos: number; + followers: number; + following: number; + created_at: string; + public_gists: number; +} + +interface GitHubRepoInfo { + name: string; + full_name: string; + html_url: string; + description: string; + stargazers_count: number; + forks_count: number; + default_branch: string; + updated_at: string; + languages_url: string; +} + +interface GitHubOrganization { + login: string; + avatar_url: string; + html_url: string; +} + +interface GitHubEvent { + id: string; + type: string; + repo: { + name: string; + }; + created_at: string; +} + +interface GitHubLanguageStats { + [language: string]: number; +} + +interface GitHubStats { + repos: GitHubRepoInfo[]; + totalStars: number; + totalForks: number; + organizations: GitHubOrganization[]; + recentActivity: GitHubEvent[]; + languages: GitHubLanguageStats; + totalGists: number; +} + +interface GitHubConnection { + user: GitHubUserResponse | null; + token: string; + tokenType: 'classic' | 'fine-grained'; + stats?: GitHubStats; +} + +export function GithubConnection() { + const [connection, setConnection] = useState({ + user: null, + token: '', + tokenType: 'classic', + }); + const [isLoading, setIsLoading] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + const [isFetchingStats, setIsFetchingStats] = useState(false); + const [isStatsExpanded, setIsStatsExpanded] = useState(false); + + const fetchGitHubStats = async (token: string) => { + try { + setIsFetchingStats(true); + + const reposResponse = await fetch( + 'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator', + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!reposResponse.ok) { + throw new Error('Failed to fetch repositories'); + } + + const repos = (await reposResponse.json()) as GitHubRepoInfo[]; + + const orgsResponse = await fetch('https://api.github.com/user/orgs', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!orgsResponse.ok) { + throw new Error('Failed to fetch organizations'); + } + + const organizations = (await orgsResponse.json()) as GitHubOrganization[]; + + const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!eventsResponse.ok) { + throw new Error('Failed to fetch events'); + } + + const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5); + + const languagePromises = repos.map((repo) => + fetch(repo.languages_url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.json() as Promise>), + ); + + const repoLanguages = await Promise.all(languagePromises); + const languages: GitHubLanguageStats = {}; + + repoLanguages.forEach((repoLang) => { + Object.entries(repoLang).forEach(([lang, bytes]) => { + languages[lang] = (languages[lang] || 0) + bytes; + }); + }); + + const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0); + const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0); + const totalGists = connection.user?.public_gists || 0; + + setConnection((prev) => ({ + ...prev, + stats: { + repos, + totalStars, + totalForks, + organizations, + recentActivity, + languages, + totalGists, + }, + })); + } catch (error) { + logStore.logError('Failed to fetch GitHub stats', { error }); + toast.error('Failed to fetch GitHub statistics'); + } finally { + setIsFetchingStats(false); + } + }; + + useEffect(() => { + const savedConnection = localStorage.getItem('github_connection'); + + if (savedConnection) { + const parsed = JSON.parse(savedConnection); + + if (!parsed.tokenType) { + parsed.tokenType = 'classic'; + } + + setConnection(parsed); + + if (parsed.user && parsed.token) { + fetchGitHubStats(parsed.token); + } + } + + setIsLoading(false); + }, []); + + if (isLoading || isConnecting || isFetchingStats) { + return ; + } + + const fetchGithubUser = async (token: string) => { + try { + setIsConnecting(true); + + const response = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Invalid token or unauthorized'); + } + + const data = (await response.json()) as GitHubUserResponse; + const newConnection: GitHubConnection = { + user: data, + token, + tokenType: connection.tokenType, + }; + + localStorage.setItem('github_connection', JSON.stringify(newConnection)); + setConnection(newConnection); + + await fetchGitHubStats(token); + + toast.success('Successfully connected to GitHub'); + } catch (error) { + logStore.logError('Failed to authenticate with GitHub', { error }); + toast.error('Failed to connect to GitHub'); + setConnection({ user: null, token: '', tokenType: 'classic' }); + } finally { + setIsConnecting(false); + } + }; + + const handleConnect = async (event: React.FormEvent) => { + event.preventDefault(); + await fetchGithubUser(connection.token); + }; + + const handleDisconnect = () => { + localStorage.removeItem('github_connection'); + setConnection({ user: null, token: '', tokenType: 'classic' }); + toast.success('Disconnected from GitHub'); + }; + + return ( + +
+
+
+

GitHub Connection

+
+ +
+
+ + +
+ +
+ + setConnection((prev) => ({ ...prev, token: e.target.value }))} + disabled={isConnecting || !!connection.user} + placeholder={`Enter your GitHub ${ + connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token' + }`} + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#333333]', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-purple-500', + 'disabled:opacity-50', + )} + /> +
+ + Get your token +
+ + + + Required scopes:{' '} + {connection.tokenType === 'classic' + ? 'repo, read:org, read:user' + : 'Repository access, Organization access'} + +
+
+
+ +
+ {!connection.user ? ( + + ) : ( + + )} + + {connection.user && ( + +
+ Connected to GitHub + + )} +
+ + {connection.user && connection.stats && ( +
+ + + {isStatsExpanded && ( +
+ {connection.stats.organizations.length > 0 && ( +
+

Organizations

+
+ {connection.stats.organizations.map((org) => ( + + {org.login} + {org.login} + + ))} +
+
+ )} + + {/* Languages Section */} +
+

Top Languages

+
+ {Object.entries(connection.stats.languages) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([language]) => ( + + {language} + + ))} +
+
+ + {/* Recent Activity Section */} +
+

Recent Activity

+
+ {connection.stats.recentActivity.map((event) => ( +
+
+
+ {event.type.replace('Event', '')} + on + + {event.repo.name} + +
+
+ {new Date(event.created_at).toLocaleDateString()} at{' '} + {new Date(event.created_at).toLocaleTimeString()} +
+
+ ))} +
+
+ + {/* Additional Stats */} +
+
+
Member Since
+
+ {new Date(connection.user.created_at).toLocaleDateString()} +
+
+
+
Public Gists
+
+ {connection.stats.totalGists} +
+
+
+
Organizations
+
+ {connection.stats.organizations.length} +
+
+
+
Languages
+
+ {Object.keys(connection.stats.languages).length} +
+
+
+ + {/* Repositories Section */} +

Recent Repositories

+ + + ); +} + +function LoadingSpinner() { + return ( +
+
+
+ Loading... +
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5881b761c4533ed196a4c2207bb218abbbfa7270 --- /dev/null +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; +import { logStore } from '~/lib/stores/logs'; +import { classNames } from '~/utils/classNames'; +import { + netlifyConnection, + isConnecting, + isFetchingStats, + updateNetlifyConnection, + fetchNetlifyStats, +} from '~/lib/stores/netlify'; +import type { NetlifyUser } from '~/types/netlify'; + +export function NetlifyConnection() { + const connection = useStore(netlifyConnection); + const connecting = useStore(isConnecting); + const fetchingStats = useStore(isFetchingStats); + const [isSitesExpanded, setIsSitesExpanded] = useState(false); + + useEffect(() => { + const fetchSites = async () => { + if (connection.user && connection.token) { + await fetchNetlifyStats(connection.token); + } + }; + fetchSites(); + }, [connection.user, connection.token]); + + const handleConnect = async (event: React.FormEvent) => { + event.preventDefault(); + isConnecting.set(true); + + try { + const response = await fetch('https://api.netlify.com/api/v1/user', { + headers: { + Authorization: `Bearer ${connection.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Invalid token or unauthorized'); + } + + const userData = (await response.json()) as NetlifyUser; + updateNetlifyConnection({ + user: userData, + token: connection.token, + }); + + await fetchNetlifyStats(connection.token); + toast.success('Successfully connected to Netlify'); + } catch (error) { + console.error('Auth error:', error); + logStore.logError('Failed to authenticate with Netlify', { error }); + toast.error('Failed to connect to Netlify'); + updateNetlifyConnection({ user: null, token: '' }); + } finally { + isConnecting.set(false); + } + }; + + const handleDisconnect = () => { + updateNetlifyConnection({ user: null, token: '' }); + toast.success('Disconnected from Netlify'); + }; + + return ( + +
+
+
+ +

Netlify Connection

+
+
+ + {!connection.user ? ( +
+
+ + updateNetlifyConnection({ ...connection, token: e.target.value })} + disabled={connecting} + placeholder="Enter your Netlify personal access token" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#333333]', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]', + 'disabled:opacity-50', + )} + /> + + + +
+ ) : ( +
+
+
+ + +
+ Connected to Netlify + +
+
+ +
+ {connection.user.full_name} +
+

{connection.user.full_name}

+

{connection.user.email}

+
+
+ + {fetchingStats ? ( +
+
+ Fetching Netlify sites... +
+ ) : ( +
+ + {isSitesExpanded && connection.stats?.sites?.length ? ( +
+ {connection.stats.sites.map((site) => ( + +
+
+
+
+ {site.name} +
+
+ + {site.url} + + {site.published_deploy && ( + <> + + +
+ {new Date(site.published_deploy.published_at).toLocaleDateString()} + + + )} +
+
+ {site.build_settings?.provider && ( +
+ +
+ {site.build_settings.provider} + +
+ )} +
+ + ))} +
+ ) : isSitesExpanded ? ( +
+
+ No sites found in your Netlify account +
+ ) : null} +
+ )} +
+ )} +
+ + ); +} diff --git a/app/components/@settings/tabs/connections/components/ConnectionForm.tsx b/app/components/@settings/tabs/connections/components/ConnectionForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..04210e2b5fb64f2374071d8d58df7ae2096a248b --- /dev/null +++ b/app/components/@settings/tabs/connections/components/ConnectionForm.tsx @@ -0,0 +1,180 @@ +import React, { useEffect } from 'react'; +import { classNames } from '~/utils/classNames'; +import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub'; +import Cookies from 'js-cookie'; +import { getLocalStorage } from '~/lib/persistence'; + +const GITHUB_TOKEN_KEY = 'github_token'; + +interface ConnectionFormProps { + authState: GitHubAuthState; + setAuthState: React.Dispatch>; + onSave: (e: React.FormEvent) => void; + onDisconnect: () => void; +} + +export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) { + // Check for saved token on mount + useEffect(() => { + const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY); + + if (savedToken && !authState.tokenInfo?.token) { + setAuthState((prev: GitHubAuthState) => ({ + ...prev, + tokenInfo: { + token: savedToken, + scope: [], + avatar_url: '', + name: null, + created_at: new Date().toISOString(), + followers: 0, + }, + })); + } + }, []); + + return ( +
+
+
+
+
+
+
+
+

Connection Settings

+

Configure your GitHub connection

+
+
+
+ +
+
+ + setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))} + className={classNames( + 'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base', + 'border-[#E5E5E5] dark:border-[#1A1A1A]', + 'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500', + 'transition-all duration-200', + )} + placeholder="e.g., octocat" + /> +
+ +
+
+ + + Generate new token +
+ +
+ + setAuthState((prev: GitHubAuthState) => ({ + ...prev, + tokenInfo: { + token: e.target.value, + scope: [], + avatar_url: '', + name: null, + created_at: new Date().toISOString(), + followers: 0, + }, + username: '', + isConnected: false, + isVerifying: false, + isLoadingRepos: false, + })) + } + className={classNames( + 'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base', + 'border-[#E5E5E5] dark:border-[#1A1A1A]', + 'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500', + 'transition-all duration-200', + )} + placeholder="ghp_xxxxxxxxxxxx" + /> +
+ +
+
+ {!authState.isConnected ? ( + + ) : ( + <> + + +
+ Connected + + + )} +
+ {authState.rateLimits && ( +
+
+ Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()} +
+ )} +
+ +
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx b/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3fd32ff275aa516cc03195a182e0c10c5668916a --- /dev/null +++ b/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { classNames } from '~/utils/classNames'; +import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub'; +import { GitBranch } from '@phosphor-icons/react'; + +interface GitHubBranch { + name: string; + default?: boolean; +} + +interface CreateBranchDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (branchName: string, sourceBranch: string) => void; + repository: GitHubRepoInfo; + branches?: GitHubBranch[]; +} + +export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) { + const [branchName, setBranchName] = useState(''); + const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main'); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onConfirm(branchName, sourceBranch); + setBranchName(''); + onClose(); + }; + + return ( + + + + + + Create New Branch + + +
+
+
+ + setBranchName(e.target.value)} + placeholder="feature/my-new-branch" + className={classNames( + 'w-full px-3 py-2 rounded-lg', + 'bg-[#F5F5F5] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#1A1A1A]', + 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/50', + )} + required + /> +
+ +
+ + +
+ +
+

Branch Overview

+
    +
  • + + Repository: {repository.name} +
  • + {branchName && ( +
  • +
    + New branch will be created as: {branchName} +
  • + )} +
  • +
    + Based on: {sourceBranch} +
  • +
+
+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx b/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..350c60f0b195bbb654fbf231cf43c6e982214b7a --- /dev/null +++ b/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx @@ -0,0 +1,528 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import { useState, useEffect } from 'react'; +import { toast } from 'react-toastify'; +import { motion } from 'framer-motion'; +import { getLocalStorage } from '~/lib/persistence'; +import { classNames } from '~/utils/classNames'; +import type { GitHubUserResponse } from '~/types/GitHub'; +import { logStore } from '~/lib/stores/logs'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { extractRelativePath } from '~/utils/diff'; +import { formatSize } from '~/utils/formatSize'; +import type { FileMap, File } from '~/lib/stores/files'; +import { Octokit } from '@octokit/rest'; + +interface PushToGitHubDialogProps { + isOpen: boolean; + onClose: () => void; + onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise; +} + +interface GitHubRepo { + name: string; + full_name: string; + html_url: string; + description: string; + stargazers_count: number; + forks_count: number; + default_branch: string; + updated_at: string; + language: string; + private: boolean; +} + +export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) { + const [repoName, setRepoName] = useState(''); + const [isPrivate, setIsPrivate] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [user, setUser] = useState(null); + const [recentRepos, setRecentRepos] = useState([]); + const [isFetchingRepos, setIsFetchingRepos] = useState(false); + const [showSuccessDialog, setShowSuccessDialog] = useState(false); + const [createdRepoUrl, setCreatedRepoUrl] = useState(''); + const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]); + + // Load GitHub connection on mount + useEffect(() => { + if (isOpen) { + const connection = getLocalStorage('github_connection'); + + if (connection?.user && connection?.token) { + setUser(connection.user); + + // Only fetch if we have both user and token + if (connection.token.trim()) { + fetchRecentRepos(connection.token); + } + } + } + }, [isOpen]); + + const fetchRecentRepos = async (token: string) => { + if (!token) { + logStore.logError('No GitHub token available'); + toast.error('GitHub authentication required'); + + return; + } + + try { + setIsFetchingRepos(true); + + const response = await fetch( + 'https://api.github.com/user/repos?sort=updated&per_page=5&type=all&affiliation=owner,organization_member', + { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${token.trim()}`, + }, + }, + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + + if (response.status === 401) { + toast.error('GitHub token expired. Please reconnect your account.'); + + // Clear invalid token + const connection = getLocalStorage('github_connection'); + + if (connection) { + localStorage.removeItem('github_connection'); + setUser(null); + } + } else { + logStore.logError('Failed to fetch GitHub repositories', { + status: response.status, + statusText: response.statusText, + error: errorData, + }); + toast.error(`Failed to fetch repositories: ${response.statusText}`); + } + + return; + } + + const repos = (await response.json()) as GitHubRepo[]; + setRecentRepos(repos); + } catch (error) { + logStore.logError('Failed to fetch GitHub repositories', { error }); + toast.error('Failed to fetch recent repositories'); + } finally { + setIsFetchingRepos(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const connection = getLocalStorage('github_connection'); + + if (!connection?.token || !connection?.user) { + toast.error('Please connect your GitHub account in Settings > Connections first'); + return; + } + + if (!repoName.trim()) { + toast.error('Repository name is required'); + return; + } + + setIsLoading(true); + + try { + // Check if repository exists first + const octokit = new Octokit({ auth: connection.token }); + + try { + await octokit.repos.get({ + owner: connection.user.login, + repo: repoName, + }); + + // If we get here, the repo exists + const confirmOverwrite = window.confirm( + `Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`, + ); + + if (!confirmOverwrite) { + setIsLoading(false); + return; + } + } catch (error) { + // 404 means repo doesn't exist, which is what we want for new repos + if (error instanceof Error && 'status' in error && error.status !== 404) { + throw error; + } + } + + const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate); + setCreatedRepoUrl(repoUrl); + + // Get list of pushed files + const files = workbenchStore.files.get(); + const filesList = Object.entries(files as FileMap) + .filter(([, dirent]) => dirent?.type === 'file' && !dirent.isBinary) + .map(([path, dirent]) => ({ + path: extractRelativePath(path), + size: new TextEncoder().encode((dirent as File).content || '').length, + })); + + setPushedFiles(filesList); + setShowSuccessDialog(true); + } catch (error) { + console.error('Error pushing to GitHub:', error); + toast.error('Failed to push to GitHub. Please check your repository name and try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + setRepoName(''); + setIsPrivate(false); + setShowSuccessDialog(false); + setCreatedRepoUrl(''); + onClose(); + }; + + // Success Dialog + if (showSuccessDialog) { + return ( + !open && handleClose()}> + + +
+ + +
+
+
+
+

Successfully pushed to GitHub

+
+ +
+ +
+ +
+

+ Repository URL +

+
+ + {createdRepoUrl} + + { + navigator.clipboard.writeText(createdRepoUrl); + toast.success('URL copied to clipboard'); + }} + className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark" + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > +
+ +
+
+ +
+

+ Pushed Files ({pushedFiles.length}) +

+
+ {pushedFiles.map((file) => ( +
+ {file.path} + + {formatSize(file.size)} + +
+ ))} +
+
+ +
+ +
+ View Repository + + { + navigator.clipboard.writeText(createdRepoUrl); + toast.success('URL copied to clipboard'); + }} + className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2" + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > +
+ Copy URL + + + Close + +
+
+ + +
+ + + ); + } + + if (!user) { + return ( + !open && handleClose()}> + + +
+ + +
+ +
+ +

GitHub Connection Required

+

+ Please connect your GitHub account in Settings {'>'} Connections to push your code to GitHub. +

+ +
+ Close + +
+ + +
+ + + ); + } + + return ( + !open && handleClose()}> + + +
+ + +
+
+ +
+ +
+ + Push to GitHub + +

+ Push your code to a new or existing GitHub repository +

+
+ +
+ +
+ +
+ {user.login} +
+

{user.name || user.login}

+

@{user.login}

+
+
+ +
+
+ + setRepoName(e.target.value)} + placeholder="my-awesome-project" + className="w-full px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400" + required + /> +
+ + {recentRepos.length > 0 && ( +
+ +
+ {recentRepos.map((repo) => ( + setRepoName(repo.name)} + className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group" + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + > +
+
+
+ + {repo.name} + +
+ {repo.private && ( + + Private + + )} +
+ {repo.description && ( +

+ {repo.description} +

+ )} +
+ {repo.language && ( + +
+ {repo.language} + + )} + +
+ {repo.stargazers_count.toLocaleString()} + + +
+ {repo.forks_count.toLocaleString()} + + +
+ {new Date(repo.updated_at).toLocaleDateString()} + +
+ + ))} +
+
+ )} + + {isFetchingRepos && ( +
+
+ Loading repositories... +
+ )} + +
+ setIsPrivate(e.target.checked)} + className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]" + /> + +
+ +
+ + Cancel + + + {isLoading ? ( + <> +
+ Pushing... + + ) : ( + <> +
+ Push to GitHub + + )} + +
+ +
+ + +
+ + + ); +} diff --git a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..06202850e32902678d7fad6cbe3166ce58bb7527 --- /dev/null +++ b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx @@ -0,0 +1,693 @@ +import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub'; +import { useState, useEffect } from 'react'; +import { toast } from 'react-toastify'; +import * as Dialog from '@radix-ui/react-dialog'; +import { classNames } from '~/utils/classNames'; +import { getLocalStorage } from '~/lib/persistence'; +import { motion } from 'framer-motion'; +import { formatSize } from '~/utils/formatSize'; +import { Input } from '~/components/ui/Input'; + +interface GitHubTreeResponse { + tree: Array<{ + path: string; + type: string; + size?: number; + }>; +} + +interface RepositorySelectionDialogProps { + isOpen: boolean; + onClose: () => void; + onSelect: (url: string) => void; +} + +interface SearchFilters { + language?: string; + stars?: number; + forks?: number; +} + +interface StatsDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + stats: RepositoryStats; + isLargeRepo?: boolean; +} + +function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) { + return ( + !open && onClose()}> + + +
+ + +
+
+

Repository Overview

+
+

Repository Statistics:

+
+
+ + Total Files: {stats.totalFiles} +
+
+ + Total Size: {formatSize(stats.totalSize)} +
+
+ + + Languages:{' '} + {Object.entries(stats.languages) + .sort(([, a], [, b]) => b - a) + .slice(0, 3) + .map(([lang, size]) => `${lang} (${formatSize(size)})`) + .join(', ')} + +
+ {stats.hasPackageJson && ( +
+ + Has package.json +
+ )} + {stats.hasDependencies && ( +
+ + Has dependencies +
+ )} +
+
+ {isLargeRepo && ( +
+ +
+ This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while + and could impact performance. +
+
+ )} +
+
+
+ + +
+
+
+
+
+
+ ); +} + +export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) { + const [selectedRepository, setSelectedRepository] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [repositories, setRepositories] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [activeTab, setActiveTab] = useState<'my-repos' | 'search' | 'url'>('my-repos'); + const [customUrl, setCustomUrl] = useState(''); + const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]); + const [selectedBranch, setSelectedBranch] = useState(''); + const [filters, setFilters] = useState({}); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [stats, setStats] = useState(null); + const [showStatsDialog, setShowStatsDialog] = useState(false); + const [currentStats, setCurrentStats] = useState(null); + const [pendingGitUrl, setPendingGitUrl] = useState(''); + + // Fetch user's repositories when dialog opens + useEffect(() => { + if (isOpen && activeTab === 'my-repos') { + fetchUserRepos(); + } + }, [isOpen, activeTab]); + + const fetchUserRepos = async () => { + const connection = getLocalStorage('github_connection'); + + if (!connection?.token) { + toast.error('Please connect your GitHub account first'); + return; + } + + setIsLoading(true); + + try { + const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', { + headers: { + Authorization: `Bearer ${connection.token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch repositories'); + } + + const data = await response.json(); + + // Add type assertion and validation + if ( + Array.isArray(data) && + data.every((item) => typeof item === 'object' && item !== null && 'full_name' in item) + ) { + setRepositories(data as GitHubRepoInfo[]); + } else { + throw new Error('Invalid repository data format'); + } + } catch (error) { + console.error('Error fetching repos:', error); + toast.error('Failed to fetch your repositories'); + } finally { + setIsLoading(false); + } + }; + + const handleSearch = async (query: string) => { + setIsLoading(true); + setSearchResults([]); + + try { + let searchQuery = query; + + if (filters.language) { + searchQuery += ` language:${filters.language}`; + } + + if (filters.stars) { + searchQuery += ` stars:>${filters.stars}`; + } + + if (filters.forks) { + searchQuery += ` forks:>${filters.forks}`; + } + + const response = await fetch( + `https://api.github.com/search/repositories?q=${encodeURIComponent(searchQuery)}&sort=stars&order=desc`, + { + headers: { + Accept: 'application/vnd.github.v3+json', + }, + }, + ); + + if (!response.ok) { + throw new Error('Failed to search repositories'); + } + + const data = await response.json(); + + // Add type assertion and validation + if (typeof data === 'object' && data !== null && 'items' in data && Array.isArray(data.items)) { + setSearchResults(data.items as GitHubRepoInfo[]); + } else { + throw new Error('Invalid search results format'); + } + } catch (error) { + console.error('Error searching repos:', error); + toast.error('Failed to search repositories'); + } finally { + setIsLoading(false); + } + }; + + const fetchBranches = async (repo: GitHubRepoInfo) => { + setIsLoading(true); + + try { + const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, { + headers: { + Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch branches'); + } + + const data = await response.json(); + + // Add type assertion and validation + if (Array.isArray(data) && data.every((item) => typeof item === 'object' && item !== null && 'name' in item)) { + setBranches( + data.map((branch) => ({ + name: branch.name, + default: branch.name === repo.default_branch, + })), + ); + } else { + throw new Error('Invalid branch data format'); + } + } catch (error) { + console.error('Error fetching branches:', error); + toast.error('Failed to fetch branches'); + } finally { + setIsLoading(false); + } + }; + + const handleRepoSelect = async (repo: GitHubRepoInfo) => { + setSelectedRepository(repo); + await fetchBranches(repo); + }; + + const formatGitUrl = (url: string): string => { + // Remove any tree references and ensure .git extension + const baseUrl = url + .replace(/\/tree\/[^/]+/, '') // Remove /tree/branch-name + .replace(/\/$/, '') // Remove trailing slash + .replace(/\.git$/, ''); // Remove .git if present + return `${baseUrl}.git`; + }; + + const verifyRepository = async (repoUrl: string): Promise => { + try { + const [owner, repo] = repoUrl + .replace(/\.git$/, '') + .split('/') + .slice(-2); + + const connection = getLocalStorage('github_connection'); + const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {}; + + // Fetch repository tree + const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, { + headers, + }); + + if (!treeResponse.ok) { + throw new Error('Failed to fetch repository structure'); + } + + const treeData = (await treeResponse.json()) as GitHubTreeResponse; + + // Calculate repository stats + let totalSize = 0; + let totalFiles = 0; + const languages: { [key: string]: number } = {}; + let hasPackageJson = false; + let hasDependencies = false; + + for (const file of treeData.tree) { + if (file.type === 'blob') { + totalFiles++; + + if (file.size) { + totalSize += file.size; + } + + // Check for package.json + if (file.path === 'package.json') { + hasPackageJson = true; + + // Fetch package.json content to check dependencies + const contentResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/package.json`, { + headers, + }); + + if (contentResponse.ok) { + const content = (await contentResponse.json()) as GitHubContent; + const packageJson = JSON.parse(Buffer.from(content.content, 'base64').toString()); + hasDependencies = !!( + packageJson.dependencies || + packageJson.devDependencies || + packageJson.peerDependencies + ); + } + } + + // Detect language based on file extension + const ext = file.path.split('.').pop()?.toLowerCase(); + + if (ext) { + languages[ext] = (languages[ext] || 0) + (file.size || 0); + } + } + } + + const stats: RepositoryStats = { + totalFiles, + totalSize, + languages, + hasPackageJson, + hasDependencies, + }; + + setStats(stats); + + return stats; + } catch (error) { + console.error('Error verifying repository:', error); + toast.error('Failed to verify repository'); + + return null; + } + }; + + const handleImport = async () => { + try { + let gitUrl: string; + + if (activeTab === 'url' && customUrl) { + gitUrl = formatGitUrl(customUrl); + } else if (selectedRepository) { + gitUrl = formatGitUrl(selectedRepository.html_url); + + if (selectedBranch) { + gitUrl = `${gitUrl}#${selectedBranch}`; + } + } else { + return; + } + + // Verify repository before importing + const stats = await verifyRepository(gitUrl); + + if (!stats) { + return; + } + + setCurrentStats(stats); + setPendingGitUrl(gitUrl); + setShowStatsDialog(true); + } catch (error) { + console.error('Error preparing repository:', error); + toast.error('Failed to prepare repository. Please try again.'); + } + }; + + const handleStatsConfirm = () => { + setShowStatsDialog(false); + + if (pendingGitUrl) { + onSelect(pendingGitUrl); + onClose(); + } + }; + + const handleFilterChange = (key: keyof SearchFilters, value: string) => { + let parsedValue: string | number | undefined = value; + + if (key === 'stars' || key === 'forks') { + parsedValue = value ? parseInt(value, 10) : undefined; + } + + setFilters((prev) => ({ ...prev, [key]: parsedValue })); + handleSearch(searchQuery); + }; + + // Handle dialog close properly + const handleClose = () => { + setIsLoading(false); // Reset loading state + setSearchQuery(''); // Reset search + setSearchResults([]); // Reset results + onClose(); + }; + + return ( + { + if (!open) { + handleClose(); + } + }} + > + + + +
+ + Import GitHub Repository + + + +
+ +
+
+ setActiveTab('my-repos')}> + + My Repos + + setActiveTab('search')}> + + Search + + setActiveTab('url')}> + + URL + +
+ + {activeTab === 'url' ? ( +
+ setCustomUrl(e.target.value)} + className={classNames('w-full', { + 'border-red-500': false, + })} + /> + +
+ ) : ( + <> + {activeTab === 'search' && ( +
+
+ { + setSearchQuery(e.target.value); + handleSearch(e.target.value); + }} + className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary" + /> + +
+
+ { + setFilters({ ...filters, language: e.target.value }); + handleSearch(searchQuery); + }} + className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" + /> + handleFilterChange('stars', e.target.value)} + className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" + /> +
+ handleFilterChange('forks', e.target.value)} + className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" + /> +
+ )} + +
+ {selectedRepository ? ( +
+
+ +

{selectedRepository.full_name}

+
+
+ + + +
+
+ ) : ( + + )} +
+ + )} +
+
+
+ {currentStats && ( + 50 * 1024 * 1024} + /> + )} +
+ ); +} + +function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +function RepositoryList({ + repos, + isLoading, + onSelect, + activeTab, +}: { + repos: GitHubRepoInfo[]; + isLoading: boolean; + onSelect: (repo: GitHubRepoInfo) => void; + activeTab: string; +}) { + if (isLoading) { + return ( +
+ + Loading repositories... +
+ ); + } + + if (repos.length === 0) { + return ( +
+ +

{activeTab === 'my-repos' ? 'No repositories found' : 'Search for repositories'}

+
+ ); + } + + return repos.map((repo) => onSelect(repo)} />); +} + +function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) { + return ( +
+
+
+ +

{repo.name}

+
+ +
+ {repo.description &&

{repo.description}

} +
+ {repo.language && ( + + + {repo.language} + + )} + + + {repo.stargazers_count.toLocaleString()} + + + + {new Date(repo.updated_at).toLocaleDateString()} + +
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/types/GitHub.ts b/app/components/@settings/tabs/connections/types/GitHub.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2f1af6bcaad63d446c6824956da7c5efd0f6b0c --- /dev/null +++ b/app/components/@settings/tabs/connections/types/GitHub.ts @@ -0,0 +1,95 @@ +export interface GitHubUserResponse { + login: string; + avatar_url: string; + html_url: string; + name: string; + bio: string; + public_repos: number; + followers: number; + following: number; + public_gists: number; + created_at: string; + updated_at: string; +} + +export interface GitHubRepoInfo { + name: string; + full_name: string; + html_url: string; + description: string; + stargazers_count: number; + forks_count: number; + default_branch: string; + updated_at: string; + language: string; + languages_url: string; +} + +export interface GitHubOrganization { + login: string; + avatar_url: string; + description: string; + html_url: string; +} + +export interface GitHubEvent { + id: string; + type: string; + created_at: string; + repo: { + name: string; + url: string; + }; + payload: { + action?: string; + ref?: string; + ref_type?: string; + description?: string; + }; +} + +export interface GitHubLanguageStats { + [key: string]: number; +} + +export interface GitHubStats { + repos: GitHubRepoInfo[]; + totalStars: number; + totalForks: number; + organizations: GitHubOrganization[]; + recentActivity: GitHubEvent[]; + languages: GitHubLanguageStats; + totalGists: number; +} + +export interface GitHubConnection { + user: GitHubUserResponse | null; + token: string; + tokenType: 'classic' | 'fine-grained'; + stats?: GitHubStats; +} + +export interface GitHubTokenInfo { + token: string; + scope: string[]; + avatar_url: string; + name: string | null; + created_at: string; + followers: number; +} + +export interface GitHubRateLimits { + limit: number; + remaining: number; + reset: Date; + used: number; +} + +export interface GitHubAuthState { + username: string; + tokenInfo: GitHubTokenInfo | null; + isConnected: boolean; + isVerifying: boolean; + isLoadingRepos: boolean; + rateLimits?: GitHubRateLimits; +} diff --git a/app/components/@settings/tabs/data/DataTab.tsx b/app/components/@settings/tabs/data/DataTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..47e34ad4d65c13955f2a155c8974a26aad8b8b97 --- /dev/null +++ b/app/components/@settings/tabs/data/DataTab.tsx @@ -0,0 +1,452 @@ +import { useState, useRef } from 'react'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; +import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog'; +import { db, getAll, deleteById } from '~/lib/persistence'; + +export default function DataTab() { + const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false); + const [isImportingKeys, setIsImportingKeys] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false); + const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false); + const fileInputRef = useRef(null); + const apiKeyFileInputRef = useRef(null); + + const handleExportAllChats = async () => { + try { + if (!db) { + throw new Error('Database not initialized'); + } + + // Get all chats from IndexedDB + const allChats = await getAll(db); + const exportData = { + chats: allChats, + exportDate: new Date().toISOString(), + }; + + // Download as JSON + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-chats-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success('Chats exported successfully'); + } catch (error) { + console.error('Export error:', error); + toast.error('Failed to export chats'); + } + }; + + const handleExportSettings = () => { + try { + const settings = { + userProfile: localStorage.getItem('bolt_user_profile'), + settings: localStorage.getItem('bolt_settings'), + exportDate: new Date().toISOString(), + }; + + const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-settings-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success('Settings exported successfully'); + } catch (error) { + console.error('Export error:', error); + toast.error('Failed to export settings'); + } + }; + + const handleImportSettings = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + try { + const content = await file.text(); + const settings = JSON.parse(content); + + if (settings.userProfile) { + localStorage.setItem('bolt_user_profile', settings.userProfile); + } + + if (settings.settings) { + localStorage.setItem('bolt_settings', settings.settings); + } + + window.location.reload(); // Reload to apply settings + toast.success('Settings imported successfully'); + } catch (error) { + console.error('Import error:', error); + toast.error('Failed to import settings'); + } + }; + + const handleImportAPIKeys = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + setIsImportingKeys(true); + + try { + const content = await file.text(); + const keys = JSON.parse(content); + + // Validate and save each key + Object.entries(keys).forEach(([key, value]) => { + if (typeof value !== 'string') { + throw new Error(`Invalid value for key: ${key}`); + } + + localStorage.setItem(`bolt_${key.toLowerCase()}`, value); + }); + + toast.success('API keys imported successfully'); + } catch (error) { + console.error('Error importing API keys:', error); + toast.error('Failed to import API keys'); + } finally { + setIsImportingKeys(false); + + if (apiKeyFileInputRef.current) { + apiKeyFileInputRef.current.value = ''; + } + } + }; + + const handleDownloadTemplate = () => { + setIsDownloadingTemplate(true); + + try { + const template = { + Anthropic_API_KEY: '', + OpenAI_API_KEY: '', + Google_API_KEY: '', + Groq_API_KEY: '', + HuggingFace_API_KEY: '', + OpenRouter_API_KEY: '', + Deepseek_API_KEY: '', + Mistral_API_KEY: '', + OpenAILike_API_KEY: '', + Together_API_KEY: '', + xAI_API_KEY: '', + Perplexity_API_KEY: '', + Cohere_API_KEY: '', + AzureOpenAI_API_KEY: '', + OPENAI_LIKE_API_BASE_URL: '', + LMSTUDIO_API_BASE_URL: '', + OLLAMA_API_BASE_URL: '', + TOGETHER_API_BASE_URL: '', + }; + + const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'bolt-api-keys-template.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success('Template downloaded successfully'); + } catch (error) { + console.error('Error downloading template:', error); + toast.error('Failed to download template'); + } finally { + setIsDownloadingTemplate(false); + } + }; + + const handleResetSettings = async () => { + setIsResetting(true); + + try { + // Clear all stored settings from localStorage + localStorage.removeItem('bolt_user_profile'); + localStorage.removeItem('bolt_settings'); + localStorage.removeItem('bolt_chat_history'); + + // Clear all data from IndexedDB + if (!db) { + throw new Error('Database not initialized'); + } + + // Get all chats and delete them + const chats = await getAll(db as IDBDatabase); + const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id)); + await Promise.all(deletePromises); + + // Close the dialog first + setShowResetInlineConfirm(false); + + // Then reload and show success message + window.location.reload(); + toast.success('Settings reset successfully'); + } catch (error) { + console.error('Reset error:', error); + setShowResetInlineConfirm(false); + toast.error('Failed to reset settings'); + } finally { + setIsResetting(false); + } + }; + + const handleDeleteAllChats = async () => { + setIsDeleting(true); + + try { + // Clear chat history from localStorage + localStorage.removeItem('bolt_chat_history'); + + // Clear chats from IndexedDB + if (!db) { + throw new Error('Database not initialized'); + } + + // Get all chats and delete them one by one + const chats = await getAll(db as IDBDatabase); + const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id)); + await Promise.all(deletePromises); + + // Close the dialog first + setShowDeleteInlineConfirm(false); + + // Then show the success message + toast.success('Chat history deleted successfully'); + } catch (error) { + console.error('Delete error:', error); + setShowDeleteInlineConfirm(false); + toast.error('Failed to delete chat history'); + } finally { + setIsDeleting(false); + } + }; + + return ( +
+ + {/* Reset Settings Dialog */} + + +
+
+
+ Reset All Settings? +
+

+ This will reset all your settings to their default values. This action cannot be undone. +

+
+ + + + + {isResetting ? ( +
+ ) : ( +
+ )} + Reset Settings + +
+
+
+
+ + {/* Delete Confirmation Dialog */} + + +
+
+
+ Delete All Chats? +
+

+ This will permanently delete all your chat history. This action cannot be undone. +

+
+ + + + + {isDeleting ? ( +
+ ) : ( +
+ )} + Delete All + +
+
+
+
+ + {/* Chat History Section */} + +
+
+

Chat History

+
+

Export or delete all your chat history.

+
+ +
+ Export All Chats + + setShowDeleteInlineConfirm(true)} + > +
+ Delete All Chats + +
+ + + {/* Settings Backup Section */} + +
+
+

Settings Backup

+
+

+ Export your settings to a JSON file or import settings from a previously exported file. +

+
+ +
+ Export Settings + + fileInputRef.current?.click()} + > +
+ Import Settings + + setShowResetInlineConfirm(true)} + > +
+ Reset Settings + +
+ + + {/* API Keys Management Section */} + +
+
+

API Keys Management

+
+

+ Import API keys from a JSON file or download a template to fill in your keys. +

+
+ + + {isDownloadingTemplate ? ( +
+ ) : ( +
+ )} + Download Template + + apiKeyFileInputRef.current?.click()} + disabled={isImportingKeys} + > + {isImportingKeys ? ( +
+ ) : ( +
+ )} + Import API Keys + +
+ +
+ ); +} diff --git a/app/components/@settings/tabs/debug/DebugTab.tsx b/app/components/@settings/tabs/debug/DebugTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e31ae4733e418cd137bb40def1c9cd3935402994 --- /dev/null +++ b/app/components/@settings/tabs/debug/DebugTab.tsx @@ -0,0 +1,2045 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { toast } from 'react-toastify'; +import { classNames } from '~/utils/classNames'; +import { logStore, type LogEntry } from '~/lib/stores/logs'; +import { useStore } from '@nanostores/react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible'; +import { Progress } from '~/components/ui/Progress'; +import { ScrollArea } from '~/components/ui/ScrollArea'; +import { Badge } from '~/components/ui/Badge'; +import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; +import { jsPDF } from 'jspdf'; +import { useSettings } from '~/lib/hooks/useSettings'; + +interface SystemInfo { + os: string; + arch: string; + platform: string; + cpus: string; + memory: { + total: string; + free: string; + used: string; + percentage: number; + }; + node: string; + browser: { + name: string; + version: string; + language: string; + userAgent: string; + cookiesEnabled: boolean; + online: boolean; + platform: string; + cores: number; + }; + screen: { + width: number; + height: number; + colorDepth: number; + pixelRatio: number; + }; + time: { + timezone: string; + offset: number; + locale: string; + }; + performance: { + memory: { + jsHeapSizeLimit: number; + totalJSHeapSize: number; + usedJSHeapSize: number; + usagePercentage: number; + }; + timing: { + loadTime: number; + domReadyTime: number; + readyStart: number; + redirectTime: number; + appcacheTime: number; + unloadEventTime: number; + lookupDomainTime: number; + connectTime: number; + requestTime: number; + initDomTreeTime: number; + loadEventTime: number; + }; + navigation: { + type: number; + redirectCount: number; + }; + }; + network: { + downlink: number; + effectiveType: string; + rtt: number; + saveData: boolean; + type: string; + }; + battery?: { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; + }; + storage: { + quota: number; + usage: number; + persistent: boolean; + temporary: boolean; + }; +} + +interface GitHubRepoInfo { + fullName: string; + defaultBranch: string; + stars: number; + forks: number; + openIssues?: number; +} + +interface GitInfo { + local: { + commitHash: string; + branch: string; + commitTime: string; + author: string; + email: string; + remoteUrl: string; + repoName: string; + }; + github?: { + currentRepo: GitHubRepoInfo; + upstream?: GitHubRepoInfo; + }; + isForked?: boolean; +} + +interface WebAppInfo { + name: string; + version: string; + description: string; + license: string; + environment: string; + timestamp: string; + runtimeInfo: { + nodeVersion: string; + }; + dependencies: { + production: Array<{ name: string; version: string; type: string }>; + development: Array<{ name: string; version: string; type: string }>; + peer: Array<{ name: string; version: string; type: string }>; + optional: Array<{ name: string; version: string; type: string }>; + }; + gitInfo: GitInfo; +} + +// Add Ollama service status interface +interface OllamaServiceStatus { + isRunning: boolean; + lastChecked: Date; + error?: string; + models?: Array<{ + name: string; + size: string; + quantization: string; + }>; +} + +interface ExportFormat { + id: string; + label: string; + icon: string; + handler: () => void; +} + +const DependencySection = ({ + title, + deps, +}: { + title: string; + deps: Array<{ name: string; version: string; type: string }>; +}) => { + const [isOpen, setIsOpen] = useState(false); + + if (deps.length === 0) { + return null; + } + + return ( + + +
+
+ + {title} Dependencies ({deps.length}) + +
+
+ {isOpen ? 'Hide' : 'Show'} +
+
+ + + +
+ {deps.map((dep) => ( +
+ {dep.name} + {dep.version} +
+ ))} +
+
+
+ + ); +}; + +export default function DebugTab() { + const [systemInfo, setSystemInfo] = useState(null); + const [webAppInfo, setWebAppInfo] = useState(null); + const [ollamaStatus, setOllamaStatus] = useState({ + isRunning: false, + lastChecked: new Date(), + }); + const [loading, setLoading] = useState({ + systemInfo: false, + webAppInfo: false, + errors: false, + performance: false, + }); + const [openSections, setOpenSections] = useState({ + system: false, + webapp: false, + errors: false, + performance: false, + }); + + const { providers } = useSettings(); + + // Subscribe to logStore updates + const logs = useStore(logStore.logs); + const errorLogs = useMemo(() => { + return Object.values(logs).filter( + (log): log is LogEntry => typeof log === 'object' && log !== null && 'level' in log && log.level === 'error', + ); + }, [logs]); + + // Set up error listeners when component mounts + useEffect(() => { + const handleError = (event: ErrorEvent) => { + logStore.logError(event.message, event.error, { + filename: event.filename, + lineNumber: event.lineno, + columnNumber: event.colno, + }); + }; + + const handleRejection = (event: PromiseRejectionEvent) => { + logStore.logError('Unhandled Promise Rejection', event.reason); + }; + + window.addEventListener('error', handleError); + window.addEventListener('unhandledrejection', handleRejection); + + return () => { + window.removeEventListener('error', handleError); + window.removeEventListener('unhandledrejection', handleRejection); + }; + }, []); + + // Check for errors when the errors section is opened + useEffect(() => { + if (openSections.errors) { + checkErrors(); + } + }, [openSections.errors]); + + // Load initial data when component mounts + useEffect(() => { + const loadInitialData = async () => { + await Promise.all([getSystemInfo(), getWebAppInfo()]); + }; + + loadInitialData(); + }, []); + + // Refresh data when sections are opened + useEffect(() => { + if (openSections.system) { + getSystemInfo(); + } + + if (openSections.webapp) { + getWebAppInfo(); + } + }, [openSections.system, openSections.webapp]); + + // Add periodic refresh of git info + useEffect(() => { + if (!openSections.webapp) { + return undefined; + } + + // Initial fetch + const fetchGitInfo = async () => { + try { + const response = await fetch('/api/system/git-info'); + const updatedGitInfo = (await response.json()) as GitInfo; + + setWebAppInfo((prev) => { + if (!prev) { + return null; + } + + // Only update if the data has changed + if (JSON.stringify(prev.gitInfo) === JSON.stringify(updatedGitInfo)) { + return prev; + } + + return { + ...prev, + gitInfo: updatedGitInfo, + }; + }); + } catch (error) { + console.error('Failed to fetch git info:', error); + } + }; + + fetchGitInfo(); + + // Refresh every 5 minutes instead of every second + const interval = setInterval(fetchGitInfo, 5 * 60 * 1000); + + return () => clearInterval(interval); + }, [openSections.webapp]); + + const getSystemInfo = async () => { + try { + setLoading((prev) => ({ ...prev, systemInfo: true })); + + // Get browser info + const ua = navigator.userAgent; + const browserName = ua.includes('Firefox') + ? 'Firefox' + : ua.includes('Chrome') + ? 'Chrome' + : ua.includes('Safari') + ? 'Safari' + : ua.includes('Edge') + ? 'Edge' + : 'Unknown'; + const browserVersion = ua.match(/(Firefox|Chrome|Safari|Edge)\/([0-9.]+)/)?.[2] || 'Unknown'; + + // Get performance metrics + const memory = (performance as any).memory || {}; + const timing = performance.timing; + const navigation = performance.navigation; + const connection = (navigator as any).connection; + + // Get battery info + let batteryInfo; + + try { + const battery = await (navigator as any).getBattery(); + batteryInfo = { + charging: battery.charging, + chargingTime: battery.chargingTime, + dischargingTime: battery.dischargingTime, + level: battery.level * 100, + }; + } catch { + console.log('Battery API not supported'); + } + + // Get storage info + let storageInfo = { + quota: 0, + usage: 0, + persistent: false, + temporary: false, + }; + + try { + const storage = await navigator.storage.estimate(); + const persistent = await navigator.storage.persist(); + storageInfo = { + quota: storage.quota || 0, + usage: storage.usage || 0, + persistent, + temporary: !persistent, + }; + } catch { + console.log('Storage API not supported'); + } + + // Get memory info from browser performance API + const performanceMemory = (performance as any).memory || {}; + const totalMemory = performanceMemory.jsHeapSizeLimit || 0; + const usedMemory = performanceMemory.usedJSHeapSize || 0; + const freeMemory = totalMemory - usedMemory; + const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0; + + const systemInfo: SystemInfo = { + os: navigator.platform, + arch: navigator.userAgent.includes('x64') ? 'x64' : navigator.userAgent.includes('arm') ? 'arm' : 'unknown', + platform: navigator.platform, + cpus: navigator.hardwareConcurrency + ' cores', + memory: { + total: formatBytes(totalMemory), + free: formatBytes(freeMemory), + used: formatBytes(usedMemory), + percentage: Math.round(memoryPercentage), + }, + node: 'browser', + browser: { + name: browserName, + version: browserVersion, + language: navigator.language, + userAgent: navigator.userAgent, + cookiesEnabled: navigator.cookieEnabled, + online: navigator.onLine, + platform: navigator.platform, + cores: navigator.hardwareConcurrency, + }, + screen: { + width: window.screen.width, + height: window.screen.height, + colorDepth: window.screen.colorDepth, + pixelRatio: window.devicePixelRatio, + }, + time: { + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + offset: new Date().getTimezoneOffset(), + locale: navigator.language, + }, + performance: { + memory: { + jsHeapSizeLimit: memory.jsHeapSizeLimit || 0, + totalJSHeapSize: memory.totalJSHeapSize || 0, + usedJSHeapSize: memory.usedJSHeapSize || 0, + usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0, + }, + timing: { + loadTime: timing.loadEventEnd - timing.navigationStart, + domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart, + readyStart: timing.fetchStart - timing.navigationStart, + redirectTime: timing.redirectEnd - timing.redirectStart, + appcacheTime: timing.domainLookupStart - timing.fetchStart, + unloadEventTime: timing.unloadEventEnd - timing.unloadEventStart, + lookupDomainTime: timing.domainLookupEnd - timing.domainLookupStart, + connectTime: timing.connectEnd - timing.connectStart, + requestTime: timing.responseEnd - timing.requestStart, + initDomTreeTime: timing.domInteractive - timing.responseEnd, + loadEventTime: timing.loadEventEnd - timing.loadEventStart, + }, + navigation: { + type: navigation.type, + redirectCount: navigation.redirectCount, + }, + }, + network: { + downlink: connection?.downlink || 0, + effectiveType: connection?.effectiveType || 'unknown', + rtt: connection?.rtt || 0, + saveData: connection?.saveData || false, + type: connection?.type || 'unknown', + }, + battery: batteryInfo, + storage: storageInfo, + }; + + setSystemInfo(systemInfo); + toast.success('System information updated'); + } catch (error) { + toast.error('Failed to get system information'); + console.error('Failed to get system information:', error); + } finally { + setLoading((prev) => ({ ...prev, systemInfo: false })); + } + }; + + const getWebAppInfo = async () => { + try { + setLoading((prev) => ({ ...prev, webAppInfo: true })); + + const [appResponse, gitResponse] = await Promise.all([ + fetch('/api/system/app-info'), + fetch('/api/system/git-info'), + ]); + + if (!appResponse.ok || !gitResponse.ok) { + throw new Error('Failed to fetch webapp info'); + } + + const appData = (await appResponse.json()) as Omit; + const gitData = (await gitResponse.json()) as GitInfo; + + console.log('Git Info Response:', gitData); // Add logging to debug + + setWebAppInfo({ + ...appData, + gitInfo: gitData, + }); + + toast.success('WebApp information updated'); + + return true; + } catch (error) { + console.error('Failed to fetch webapp info:', error); + toast.error('Failed to fetch webapp information'); + setWebAppInfo(null); + + return false; + } finally { + setLoading((prev) => ({ ...prev, webAppInfo: false })); + } + }; + + // Helper function to format bytes to human readable format + const formatBytes = (bytes: number) => { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${Math.round(size)} ${units[unitIndex]}`; + }; + + const handleLogPerformance = () => { + try { + setLoading((prev) => ({ ...prev, performance: true })); + + // Get performance metrics using modern Performance API + const performanceEntries = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + const memory = (performance as any).memory; + + // Calculate timing metrics + const timingMetrics = { + loadTime: performanceEntries.loadEventEnd - performanceEntries.startTime, + domReadyTime: performanceEntries.domContentLoadedEventEnd - performanceEntries.startTime, + fetchTime: performanceEntries.responseEnd - performanceEntries.fetchStart, + redirectTime: performanceEntries.redirectEnd - performanceEntries.redirectStart, + dnsTime: performanceEntries.domainLookupEnd - performanceEntries.domainLookupStart, + tcpTime: performanceEntries.connectEnd - performanceEntries.connectStart, + ttfb: performanceEntries.responseStart - performanceEntries.requestStart, + processingTime: performanceEntries.loadEventEnd - performanceEntries.responseEnd, + }; + + // Get resource timing data + const resourceEntries = performance.getEntriesByType('resource'); + const resourceStats = { + totalResources: resourceEntries.length, + totalSize: resourceEntries.reduce((total, entry) => total + ((entry as any).transferSize || 0), 0), + totalTime: Math.max(...resourceEntries.map((entry) => entry.duration)), + }; + + // Get memory metrics + const memoryMetrics = memory + ? { + jsHeapSizeLimit: memory.jsHeapSizeLimit, + totalJSHeapSize: memory.totalJSHeapSize, + usedJSHeapSize: memory.usedJSHeapSize, + heapUtilization: (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100, + } + : null; + + // Get frame rate metrics + let fps = 0; + + if ('requestAnimationFrame' in window) { + const times: number[] = []; + + function calculateFPS(now: number) { + times.push(now); + + if (times.length > 10) { + const fps = Math.round((1000 * 10) / (now - times[0])); + times.shift(); + + return fps; + } + + requestAnimationFrame(calculateFPS); + + return 0; + } + + fps = calculateFPS(performance.now()); + } + + // Log all performance metrics + logStore.logSystem('Performance Metrics', { + timing: timingMetrics, + resources: resourceStats, + memory: memoryMetrics, + fps, + timestamp: new Date().toISOString(), + navigationEntry: { + type: performanceEntries.type, + redirectCount: performanceEntries.redirectCount, + }, + }); + + toast.success('Performance metrics logged'); + } catch (error) { + toast.error('Failed to log performance metrics'); + console.error('Failed to log performance metrics:', error); + } finally { + setLoading((prev) => ({ ...prev, performance: false })); + } + }; + + const checkErrors = async () => { + try { + setLoading((prev) => ({ ...prev, errors: true })); + + // Get errors from log store + const storedErrors = errorLogs; + + if (storedErrors.length === 0) { + toast.success('No errors found'); + } else { + toast.warning(`Found ${storedErrors.length} error(s)`); + } + } catch (error) { + toast.error('Failed to check errors'); + console.error('Failed to check errors:', error); + } finally { + setLoading((prev) => ({ ...prev, errors: false })); + } + }; + + const exportDebugInfo = () => { + try { + const debugData = { + timestamp: new Date().toISOString(), + system: systemInfo, + webApp: webAppInfo, + errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'), + performance: { + memory: (performance as any).memory || {}, + timing: performance.timing, + navigation: performance.navigation, + }, + }; + + const blob = new Blob([JSON.stringify(debugData, null, 2)], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-debug-info-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Debug information exported successfully'); + } catch (error) { + console.error('Failed to export debug info:', error); + toast.error('Failed to export debug information'); + } + }; + + const exportAsCSV = () => { + try { + const debugData = { + system: systemInfo, + webApp: webAppInfo, + errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'), + performance: { + memory: (performance as any).memory || {}, + timing: performance.timing, + navigation: performance.navigation, + }, + }; + + // Convert the data to CSV format + const csvData = [ + ['Category', 'Key', 'Value'], + ...Object.entries(debugData).flatMap(([category, data]) => + Object.entries(data || {}).map(([key, value]) => [ + category, + key, + typeof value === 'object' ? JSON.stringify(value) : String(value), + ]), + ), + ]; + + // Create CSV content + const csvContent = csvData.map((row) => row.join(',')).join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-debug-info-${new Date().toISOString()}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Debug information exported as CSV'); + } catch (error) { + console.error('Failed to export CSV:', error); + toast.error('Failed to export debug information as CSV'); + } + }; + + const exportAsPDF = () => { + try { + const debugData = { + system: systemInfo, + webApp: webAppInfo, + errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'), + performance: { + memory: (performance as any).memory || {}, + timing: performance.timing, + navigation: performance.navigation, + }, + }; + + // Create new PDF document + const doc = new jsPDF(); + const lineHeight = 7; + let yPos = 20; + const margin = 20; + const pageWidth = doc.internal.pageSize.getWidth(); + const maxLineWidth = pageWidth - 2 * margin; + + // Add key-value pair with better formatting + const addKeyValue = (key: string, value: any, indent = 0) => { + // Check if we need a new page + if (yPos > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + } + + doc.setFontSize(10); + doc.setTextColor('#374151'); + doc.setFont('helvetica', 'bold'); + + // Format the key with proper spacing + const formattedKey = key.replace(/([A-Z])/g, ' $1').trim(); + doc.text(formattedKey + ':', margin + indent, yPos); + doc.setFont('helvetica', 'normal'); + doc.setTextColor('#6B7280'); + + let valueText; + + if (typeof value === 'object' && value !== null) { + // Skip rendering if value is empty object + if (Object.keys(value).length === 0) { + return; + } + + yPos += lineHeight; + Object.entries(value).forEach(([subKey, subValue]) => { + // Check for page break before each sub-item + if (yPos > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + } + + const formattedSubKey = subKey.replace(/([A-Z])/g, ' $1').trim(); + addKeyValue(formattedSubKey, subValue, indent + 10); + }); + + return; + } else { + valueText = String(value); + } + + const valueX = margin + indent + doc.getTextWidth(formattedKey + ': '); + const maxValueWidth = maxLineWidth - indent - doc.getTextWidth(formattedKey + ': '); + const lines = doc.splitTextToSize(valueText, maxValueWidth); + + // Check if we need a new page for the value + if (yPos + lines.length * lineHeight > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + } + + doc.text(lines, valueX, yPos); + yPos += lines.length * lineHeight; + }; + + // Add section header with page break check + const addSectionHeader = (title: string) => { + // Check if we need a new page + if (yPos + 20 > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + } + + yPos += lineHeight; + doc.setFillColor('#F3F4F6'); + doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setTextColor('#111827'); + doc.setFontSize(12); + doc.text(title.toUpperCase(), margin, yPos); + doc.setFont('helvetica', 'normal'); + yPos += lineHeight * 1.5; + }; + + // Add horizontal line with page break check + const addHorizontalLine = () => { + // Check if we need a new page + if (yPos + 10 > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + + return; // Skip drawing line if we just started a new page + } + + doc.setDrawColor('#E5E5E5'); + doc.line(margin, yPos, pageWidth - margin, yPos); + yPos += lineHeight; + }; + + // Helper function to add footer to all pages + const addFooters = () => { + const totalPages = doc.internal.pages.length - 1; + + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + doc.setFontSize(8); + doc.setTextColor('#9CA3AF'); + doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, { + align: 'center', + }); + } + }; + + // Title and Header (first page only) + doc.setFillColor('#6366F1'); + doc.rect(0, 0, pageWidth, 40, 'F'); + doc.setTextColor('#FFFFFF'); + doc.setFontSize(24); + doc.setFont('helvetica', 'bold'); + doc.text('Debug Information Report', margin, 25); + yPos = 50; + + // Timestamp and metadata + doc.setTextColor('#6B7280'); + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + + const timestamp = new Date().toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + doc.text(`Generated: ${timestamp}`, margin, yPos); + yPos += lineHeight * 2; + + // System Information Section + if (debugData.system) { + addSectionHeader('System Information'); + + // OS and Architecture + addKeyValue('Operating System', debugData.system.os); + addKeyValue('Architecture', debugData.system.arch); + addKeyValue('Platform', debugData.system.platform); + addKeyValue('CPU Cores', debugData.system.cpus); + + // Memory + const memory = debugData.system.memory; + addKeyValue('Memory', { + 'Total Memory': memory.total, + 'Used Memory': memory.used, + 'Free Memory': memory.free, + Usage: memory.percentage + '%', + }); + + // Browser Information + const browser = debugData.system.browser; + addKeyValue('Browser', { + Name: browser.name, + Version: browser.version, + Language: browser.language, + Platform: browser.platform, + 'Cookies Enabled': browser.cookiesEnabled ? 'Yes' : 'No', + 'Online Status': browser.online ? 'Online' : 'Offline', + }); + + // Screen Information + const screen = debugData.system.screen; + addKeyValue('Screen', { + Resolution: `${screen.width}x${screen.height}`, + 'Color Depth': screen.colorDepth + ' bit', + 'Pixel Ratio': screen.pixelRatio + 'x', + }); + + // Time Information + const time = debugData.system.time; + addKeyValue('Time Settings', { + Timezone: time.timezone, + 'UTC Offset': time.offset / 60 + ' hours', + Locale: time.locale, + }); + + addHorizontalLine(); + } + + // Web App Information Section + if (debugData.webApp) { + addSectionHeader('Web App Information'); + + // Basic Info + addKeyValue('Application', { + Name: debugData.webApp.name, + Version: debugData.webApp.version, + Environment: debugData.webApp.environment, + 'Node Version': debugData.webApp.runtimeInfo.nodeVersion, + }); + + // Git Information + if (debugData.webApp.gitInfo) { + const gitInfo = debugData.webApp.gitInfo.local; + addKeyValue('Git Information', { + Branch: gitInfo.branch, + Commit: gitInfo.commitHash, + Author: gitInfo.author, + 'Commit Time': gitInfo.commitTime, + Repository: gitInfo.repoName, + }); + + if (debugData.webApp.gitInfo.github) { + const githubInfo = debugData.webApp.gitInfo.github.currentRepo; + addKeyValue('GitHub Information', { + Repository: githubInfo.fullName, + 'Default Branch': githubInfo.defaultBranch, + Stars: githubInfo.stars, + Forks: githubInfo.forks, + 'Open Issues': githubInfo.openIssues || 0, + }); + } + } + + addHorizontalLine(); + } + + // Performance Section + if (debugData.performance) { + addSectionHeader('Performance Metrics'); + + // Memory Usage + const memory = debugData.performance.memory || {}; + const totalHeap = memory.totalJSHeapSize || 0; + const usedHeap = memory.usedJSHeapSize || 0; + const usagePercentage = memory.usagePercentage || 0; + + addKeyValue('Memory Usage', { + 'Total Heap Size': formatBytes(totalHeap), + 'Used Heap Size': formatBytes(usedHeap), + Usage: usagePercentage.toFixed(1) + '%', + }); + + // Timing Metrics + const timing = debugData.performance.timing || {}; + const navigationStart = timing.navigationStart || 0; + const loadEventEnd = timing.loadEventEnd || 0; + const domContentLoadedEventEnd = timing.domContentLoadedEventEnd || 0; + const responseEnd = timing.responseEnd || 0; + const requestStart = timing.requestStart || 0; + + const loadTime = loadEventEnd > navigationStart ? loadEventEnd - navigationStart : 0; + const domReadyTime = + domContentLoadedEventEnd > navigationStart ? domContentLoadedEventEnd - navigationStart : 0; + const requestTime = responseEnd > requestStart ? responseEnd - requestStart : 0; + + addKeyValue('Page Load Metrics', { + 'Total Load Time': (loadTime / 1000).toFixed(2) + ' seconds', + 'DOM Ready Time': (domReadyTime / 1000).toFixed(2) + ' seconds', + 'Request Time': (requestTime / 1000).toFixed(2) + ' seconds', + }); + + // Network Information + if (debugData.system?.network) { + const network = debugData.system.network; + addKeyValue('Network Information', { + 'Connection Type': network.type || 'Unknown', + 'Effective Type': network.effectiveType || 'Unknown', + 'Download Speed': (network.downlink || 0) + ' Mbps', + 'Latency (RTT)': (network.rtt || 0) + ' ms', + 'Data Saver': network.saveData ? 'Enabled' : 'Disabled', + }); + } + + addHorizontalLine(); + } + + // Errors Section + if (debugData.errors && debugData.errors.length > 0) { + addSectionHeader('Error Log'); + + debugData.errors.forEach((error: LogEntry, index: number) => { + doc.setTextColor('#DC2626'); + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.text(`Error ${index + 1}:`, margin, yPos); + yPos += lineHeight; + + doc.setFont('helvetica', 'normal'); + doc.setTextColor('#6B7280'); + addKeyValue('Message', error.message, 10); + + if (error.stack) { + addKeyValue('Stack', error.stack, 10); + } + + if (error.source) { + addKeyValue('Source', error.source, 10); + } + + yPos += lineHeight; + }); + } + + // Add footers to all pages at the end + addFooters(); + + // Save the PDF + doc.save(`bolt-debug-info-${new Date().toISOString()}.pdf`); + toast.success('Debug information exported as PDF'); + } catch (error) { + console.error('Failed to export PDF:', error); + toast.error('Failed to export debug information as PDF'); + } + }; + + const exportAsText = () => { + try { + const debugData = { + system: systemInfo, + webApp: webAppInfo, + errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'), + performance: { + memory: (performance as any).memory || {}, + timing: performance.timing, + navigation: performance.navigation, + }, + }; + + const textContent = Object.entries(debugData) + .map(([category, data]) => { + return `${category.toUpperCase()}\n${'-'.repeat(30)}\n${JSON.stringify(data, null, 2)}\n\n`; + }) + .join('\n'); + + const blob = new Blob([textContent], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-debug-info-${new Date().toISOString()}.txt`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Debug information exported as text file'); + } catch (error) { + console.error('Failed to export text file:', error); + toast.error('Failed to export debug information as text file'); + } + }; + + const exportFormats: ExportFormat[] = [ + { + id: 'json', + label: 'Export as JSON', + icon: 'i-ph:file-json', + handler: exportDebugInfo, + }, + { + id: 'csv', + label: 'Export as CSV', + icon: 'i-ph:file-csv', + handler: exportAsCSV, + }, + { + id: 'pdf', + label: 'Export as PDF', + icon: 'i-ph:file-pdf', + handler: exportAsPDF, + }, + { + id: 'txt', + label: 'Export as Text', + icon: 'i-ph:file-text', + handler: exportAsText, + }, + ]; + + // Add Ollama health check function + const checkOllamaStatus = useCallback(async () => { + try { + const ollamaProvider = providers?.Ollama; + const baseUrl = ollamaProvider?.settings?.baseUrl || 'http://127.0.0.1:11434'; + + // First check if service is running + const versionResponse = await fetch(`${baseUrl}/api/version`); + + if (!versionResponse.ok) { + throw new Error('Service not running'); + } + + // Then fetch installed models + const modelsResponse = await fetch(`${baseUrl}/api/tags`); + + const modelsData = (await modelsResponse.json()) as { + models: Array<{ name: string; size: string; quantization: string }>; + }; + + setOllamaStatus({ + isRunning: true, + lastChecked: new Date(), + models: modelsData.models, + }); + } catch { + setOllamaStatus({ + isRunning: false, + error: 'Connection failed', + lastChecked: new Date(), + models: undefined, + }); + } + }, [providers]); + + // Monitor Ollama provider status and check periodically + useEffect(() => { + const ollamaProvider = providers?.Ollama; + + if (ollamaProvider?.settings?.enabled) { + // Check immediately when provider is enabled + checkOllamaStatus(); + + // Set up periodic checks every 10 seconds + const intervalId = setInterval(checkOllamaStatus, 10000); + + return () => clearInterval(intervalId); + } + + return undefined; + }, [providers, checkOllamaStatus]); + + // Replace the existing export button with this new component + const ExportButton = () => { + const [isOpen, setIsOpen] = useState(false); + + const handleOpenChange = useCallback((open: boolean) => { + setIsOpen(open); + }, []); + + const handleFormatClick = useCallback((handler: () => void) => { + handler(); + setIsOpen(false); + }, []); + + return ( + + + + +
+ +
+ Export Debug Information + + +
+ {exportFormats.map((format) => ( + + ))} +
+
+
+
+ ); + }; + + // Add helper function to get Ollama status text and color + const getOllamaStatus = () => { + const ollamaProvider = providers?.Ollama; + const isOllamaEnabled = ollamaProvider?.settings?.enabled; + + if (!isOllamaEnabled) { + return { + status: 'Disabled', + color: 'text-red-500', + bgColor: 'bg-red-500', + message: 'Ollama provider is disabled in settings', + }; + } + + if (!ollamaStatus.isRunning) { + return { + status: 'Not Running', + color: 'text-red-500', + bgColor: 'bg-red-500', + message: ollamaStatus.error || 'Ollama service is not running', + }; + } + + const modelCount = ollamaStatus.models?.length ?? 0; + + return { + status: 'Running', + color: 'text-green-500', + bgColor: 'bg-green-500', + message: `Ollama service is running with ${modelCount} installed models (Provider: Enabled)`, + }; + }; + + // Add type for status result + type StatusResult = { + status: string; + color: string; + bgColor: string; + message: string; + }; + + const status = getOllamaStatus() as StatusResult; + + return ( +
+ {/* Quick Stats Banner */} +
+ {/* Errors Card */} +
+
+
+
Errors
+
+
+ 0 ? 'text-red-500' : 'text-green-500')} + > + {errorLogs.length} + +
+
+
0 ? 'i-ph:warning text-red-500' : 'i-ph:check-circle text-green-500', + )} + /> + {errorLogs.length > 0 ? 'Errors detected' : 'No errors detected'} +
+
+ + {/* Memory Usage Card */} +
+
+
+
Memory Usage
+
+
+ 80 + ? 'text-red-500' + : (systemInfo?.memory?.percentage ?? 0) > 60 + ? 'text-yellow-500' + : 'text-green-500', + )} + > + {systemInfo?.memory?.percentage ?? 0}% + +
+ 80 + ? '[&>div]:bg-red-500' + : (systemInfo?.memory?.percentage ?? 0) > 60 + ? '[&>div]:bg-yellow-500' + : '[&>div]:bg-green-500', + )} + /> +
+
+ Used: {systemInfo?.memory.used ?? '0 GB'} / {systemInfo?.memory.total ?? '0 GB'} +
+
+ + {/* Page Load Time Card */} +
+
+
+
Page Load Time
+
+
+ 2000 + ? 'text-red-500' + : (systemInfo?.performance.timing.loadTime ?? 0) > 1000 + ? 'text-yellow-500' + : 'text-green-500', + )} + > + {systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) : '-'}s + +
+
+
+ DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s +
+
+ + {/* Network Speed Card */} +
+
+
+
Network Speed
+
+
+ + {systemInfo?.network.downlink ?? '-'} Mbps + +
+
+
+ RTT: {systemInfo?.network.rtt ?? '-'} ms +
+
+ + {/* Ollama Service Card - Now spans all 4 columns */} +
+
+
+
+
+
Ollama Service
+
{status.message}
+
+
+
+
+
+ + {status.status} + +
+
+
+ {ollamaStatus.lastChecked.toLocaleTimeString()} +
+
+
+ +
+ {status.status === 'Running' && ollamaStatus.models && ollamaStatus.models.length > 0 ? ( + <> +
+
+
+ Installed Models + + {ollamaStatus.models.length} + +
+
+
+
+ {ollamaStatus.models.map((model) => ( +
+
+
+ {model.name} +
+ + {Math.round(parseInt(model.size) / 1024 / 1024)}MB + +
+ ))} +
+
+ + ) : ( +
+
+
+ {status.message} +
+
+ )} +
+
+
+ + {/* Action Buttons */} +
+ + + + + + + + + +
+ + {/* System Information */} + setOpenSections((prev) => ({ ...prev, system: open }))} + className="w-full" + > + +
+
+
+

System Information

+
+
+
+ + + +
+ {systemInfo ? ( +
+
+
+
+ OS: + {systemInfo.os} +
+
+
+ Platform: + {systemInfo.platform} +
+
+
+ Architecture: + {systemInfo.arch} +
+
+
+ CPU Cores: + {systemInfo.cpus} +
+
+
+ Node Version: + {systemInfo.node} +
+
+
+ Network Type: + + {systemInfo.network.type} ({systemInfo.network.effectiveType}) + +
+
+
+ Network Speed: + + {systemInfo.network.downlink}Mbps (RTT: {systemInfo.network.rtt}ms) + +
+ {systemInfo.battery && ( +
+
+ Battery: + + {systemInfo.battery.level.toFixed(1)}% {systemInfo.battery.charging ? '(Charging)' : ''} + +
+ )} +
+
+ Storage: + + {(systemInfo.storage.usage / (1024 * 1024 * 1024)).toFixed(2)}GB /{' '} + {(systemInfo.storage.quota / (1024 * 1024 * 1024)).toFixed(2)}GB + +
+
+
+
+
+ Memory Usage: + + {systemInfo.memory.used} / {systemInfo.memory.total} ({systemInfo.memory.percentage}%) + +
+
+
+ Browser: + + {systemInfo.browser.name} {systemInfo.browser.version} + +
+
+
+ Screen: + + {systemInfo.screen.width}x{systemInfo.screen.height} ({systemInfo.screen.pixelRatio}x) + +
+
+
+ Timezone: + {systemInfo.time.timezone} +
+
+
+ Language: + {systemInfo.browser.language} +
+
+
+ JS Heap: + + {(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '} + {(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB ( + {systemInfo.performance.memory.usagePercentage.toFixed(1)}%) + +
+
+
+ Page Load: + + {(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s + +
+
+
+ DOM Ready: + + {(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s + +
+
+
+ ) : ( +
Loading system information...
+ )} +
+ + + + {/* Performance Metrics */} + setOpenSections((prev) => ({ ...prev, performance: open }))} + className="w-full" + > + +
+
+
+

Performance Metrics

+
+
+
+ + + +
+ {systemInfo && ( +
+
+
+ Page Load Time: + + {(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s + +
+
+ DOM Ready Time: + + {(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s + +
+
+ Request Time: + + {(systemInfo.performance.timing.requestTime / 1000).toFixed(2)}s + +
+
+ Redirect Time: + + {(systemInfo.performance.timing.redirectTime / 1000).toFixed(2)}s + +
+
+
+
+ JS Heap Usage: + + {(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '} + {(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB + +
+
+ Heap Utilization: + + {systemInfo.performance.memory.usagePercentage.toFixed(1)}% + +
+
+ Navigation Type: + + {systemInfo.performance.navigation.type === 0 + ? 'Navigate' + : systemInfo.performance.navigation.type === 1 + ? 'Reload' + : systemInfo.performance.navigation.type === 2 + ? 'Back/Forward' + : 'Other'} + +
+
+ Redirects: + + {systemInfo.performance.navigation.redirectCount} + +
+
+
+ )} +
+
+ + + {/* WebApp Information */} + setOpenSections((prev) => ({ ...prev, webapp: open }))} + className="w-full" + > + +
+
+
+

WebApp Information

+ {loading.webAppInfo && } +
+
+
+ + + +
+ {loading.webAppInfo ? ( +
+ +
+ ) : !webAppInfo ? ( +
+
+

Failed to load WebApp information

+ +
+ ) : ( +
+
+

Basic Information

+
+
+
+ Name: + {webAppInfo.name} +
+
+
+ Version: + {webAppInfo.version} +
+
+
+ License: + {webAppInfo.license} +
+
+
+ Environment: + {webAppInfo.environment} +
+
+
+ Node Version: + {webAppInfo.runtimeInfo.nodeVersion} +
+
+
+ +
+

Git Information

+
+
+
+ Branch: + {webAppInfo.gitInfo.local.branch} +
+
+
+ Commit: + {webAppInfo.gitInfo.local.commitHash} +
+
+
+ Author: + {webAppInfo.gitInfo.local.author} +
+
+
+ Commit Time: + {webAppInfo.gitInfo.local.commitTime} +
+ + {webAppInfo.gitInfo.github && ( + <> +
+
+
+ Repository: + + {webAppInfo.gitInfo.github.currentRepo.fullName} + {webAppInfo.gitInfo.isForked && ' (fork)'} + +
+ +
+
+
+ + {webAppInfo.gitInfo.github.currentRepo.stars} + +
+
+
+ + {webAppInfo.gitInfo.github.currentRepo.forks} + +
+
+
+ + {webAppInfo.gitInfo.github.currentRepo.openIssues} + +
+
+
+ + {webAppInfo.gitInfo.github.upstream && ( +
+
+
+ Upstream: + + {webAppInfo.gitInfo.github.upstream.fullName} + +
+ +
+
+
+ + {webAppInfo.gitInfo.github.upstream.stars} + +
+
+
+ + {webAppInfo.gitInfo.github.upstream.forks} + +
+
+
+ )} + + )} +
+
+
+ )} + + {webAppInfo && ( +
+

Dependencies

+
+ + + + +
+
+ )} +
+ + + + {/* Error Check */} + setOpenSections((prev) => ({ ...prev, errors: open }))} + className="w-full" + > + +
+
+
+

Error Check

+ {errorLogs.length > 0 && ( + + {errorLogs.length} Errors + + )} +
+
+
+ + + +
+ +
+
+ Checks for: +
    +
  • Unhandled JavaScript errors
  • +
  • Unhandled Promise rejections
  • +
  • Runtime exceptions
  • +
  • Network errors
  • +
+
+
+ Status: + + {loading.errors + ? 'Checking...' + : errorLogs.length > 0 + ? `${errorLogs.length} errors found` + : 'No errors found'} + +
+ {errorLogs.length > 0 && ( +
+
Recent Errors:
+
+ {errorLogs.map((error) => ( +
+
{error.message}
+ {error.source && ( +
+ Source: {error.source} + {error.details?.lineNumber && `:${error.details.lineNumber}`} +
+ )} + {error.stack && ( +
{error.stack}
+ )} +
+ ))} +
+
+ )} +
+
+
+
+ +
+ ); +} diff --git a/app/components/@settings/tabs/event-logs/EventLogsTab.tsx b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d28c26ebe60bc1febf8b1d68cf94479c9b7520a --- /dev/null +++ b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx @@ -0,0 +1,1013 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; +import { Switch } from '~/components/ui/Switch'; +import { logStore, type LogEntry } from '~/lib/stores/logs'; +import { useStore } from '@nanostores/react'; +import { classNames } from '~/utils/classNames'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; +import { jsPDF } from 'jspdf'; +import { toast } from 'react-toastify'; + +interface SelectOption { + value: string; + label: string; + icon?: string; + color?: string; +} + +const logLevelOptions: SelectOption[] = [ + { + value: 'all', + label: 'All Types', + icon: 'i-ph:funnel', + color: '#9333ea', + }, + { + value: 'provider', + label: 'LLM', + icon: 'i-ph:robot', + color: '#10b981', + }, + { + value: 'api', + label: 'API', + icon: 'i-ph:cloud', + color: '#3b82f6', + }, + { + value: 'error', + label: 'Errors', + icon: 'i-ph:warning-circle', + color: '#ef4444', + }, + { + value: 'warning', + label: 'Warnings', + icon: 'i-ph:warning', + color: '#f59e0b', + }, + { + value: 'info', + label: 'Info', + icon: 'i-ph:info', + color: '#3b82f6', + }, + { + value: 'debug', + label: 'Debug', + icon: 'i-ph:bug', + color: '#6b7280', + }, +]; + +interface LogEntryItemProps { + log: LogEntry; + isExpanded: boolean; + use24Hour: boolean; + showTimestamp: boolean; +} + +const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp }: LogEntryItemProps) => { + const [localExpanded, setLocalExpanded] = useState(forceExpanded); + + useEffect(() => { + setLocalExpanded(forceExpanded); + }, [forceExpanded]); + + const timestamp = useMemo(() => { + const date = new Date(log.timestamp); + return date.toLocaleTimeString('en-US', { hour12: !use24Hour }); + }, [log.timestamp, use24Hour]); + + const style = useMemo(() => { + if (log.category === 'provider') { + return { + icon: 'i-ph:robot', + color: 'text-emerald-500 dark:text-emerald-400', + bg: 'hover:bg-emerald-500/10 dark:hover:bg-emerald-500/20', + badge: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-500/10', + }; + } + + if (log.category === 'api') { + return { + icon: 'i-ph:cloud', + color: 'text-blue-500 dark:text-blue-400', + bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20', + badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10', + }; + } + + switch (log.level) { + case 'error': + return { + icon: 'i-ph:warning-circle', + color: 'text-red-500 dark:text-red-400', + bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20', + badge: 'text-red-500 bg-red-50 dark:bg-red-500/10', + }; + case 'warning': + return { + icon: 'i-ph:warning', + color: 'text-yellow-500 dark:text-yellow-400', + bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20', + badge: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10', + }; + case 'debug': + return { + icon: 'i-ph:bug', + color: 'text-gray-500 dark:text-gray-400', + bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20', + badge: 'text-gray-500 bg-gray-50 dark:bg-gray-500/10', + }; + default: + return { + icon: 'i-ph:info', + color: 'text-blue-500 dark:text-blue-400', + bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20', + badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10', + }; + } + }, [log.level, log.category]); + + const renderDetails = (details: any) => { + if (log.category === 'provider') { + return ( +
+
+ Model: {details.model} + + Tokens: {details.totalTokens} + + Duration: {details.duration}ms +
+ {details.prompt && ( +
+
Prompt:
+
+                {details.prompt}
+              
+
+ )} + {details.response && ( +
+
Response:
+
+                {details.response}
+              
+
+ )} +
+ ); + } + + if (log.category === 'api') { + return ( +
+
+ {details.method} + + Status: {details.statusCode} + + Duration: {details.duration}ms +
+
{details.url}
+ {details.request && ( +
+
Request:
+
+                {JSON.stringify(details.request, null, 2)}
+              
+
+ )} + {details.response && ( +
+
Response:
+
+                {JSON.stringify(details.response, null, 2)}
+              
+
+ )} + {details.error && ( +
+
Error:
+
+                {JSON.stringify(details.error, null, 2)}
+              
+
+ )} +
+ ); + } + + return ( +
+        {JSON.stringify(details, null, 2)}
+      
+ ); + }; + + return ( + +
+
+ +
+
{log.message}
+ {log.details && ( + <> + + {localExpanded && renderDetails(log.details)} + + )} +
+
+ {log.level} +
+ {log.category && ( +
+ {log.category} +
+ )} +
+
+
+ {showTimestamp && } +
+
+ ); +}; + +interface ExportFormat { + id: string; + label: string; + icon: string; + handler: () => void; +} + +export function EventLogsTab() { + const logs = useStore(logStore.logs); + const [selectedLevel, setSelectedLevel] = useState<'all' | string>('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [use24Hour, setUse24Hour] = useState(false); + const [autoExpand, setAutoExpand] = useState(false); + const [showTimestamps, setShowTimestamps] = useState(true); + const [showLevelFilter, setShowLevelFilter] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const levelFilterRef = useRef(null); + + const filteredLogs = useMemo(() => { + const allLogs = Object.values(logs); + + if (selectedLevel === 'all') { + return allLogs.filter((log) => + searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true, + ); + } + + return allLogs.filter((log) => { + const matchesType = log.category === selectedLevel || log.level === selectedLevel; + const matchesSearch = searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true; + + return matchesType && matchesSearch; + }); + }, [logs, selectedLevel, searchQuery]); + + // Add performance tracking on mount + useEffect(() => { + const startTime = performance.now(); + + logStore.logInfo('Event Logs tab mounted', { + type: 'component_mount', + message: 'Event Logs tab component mounted', + component: 'EventLogsTab', + }); + + return () => { + const duration = performance.now() - startTime; + logStore.logPerformanceMetric('EventLogsTab', 'mount-duration', duration); + }; + }, []); + + // Log filter changes + const handleLevelFilterChange = useCallback( + (newLevel: string) => { + logStore.logInfo('Log level filter changed', { + type: 'filter_change', + message: `Log level filter changed from ${selectedLevel} to ${newLevel}`, + component: 'EventLogsTab', + previousLevel: selectedLevel, + newLevel, + }); + setSelectedLevel(newLevel as string); + setShowLevelFilter(false); + }, + [selectedLevel], + ); + + // Log search changes with debounce + useEffect(() => { + const timeoutId = setTimeout(() => { + if (searchQuery) { + logStore.logInfo('Log search performed', { + type: 'search', + message: `Search performed with query "${searchQuery}" (${filteredLogs.length} results)`, + component: 'EventLogsTab', + query: searchQuery, + resultsCount: filteredLogs.length, + }); + } + }, 1000); + + return () => clearTimeout(timeoutId); + }, [searchQuery, filteredLogs.length]); + + // Enhanced refresh handler + const handleRefresh = useCallback(async () => { + const startTime = performance.now(); + setIsRefreshing(true); + + try { + await logStore.refreshLogs(); + + const duration = performance.now() - startTime; + + logStore.logSuccess('Logs refreshed successfully', { + type: 'refresh', + message: `Successfully refreshed ${Object.keys(logs).length} logs`, + component: 'EventLogsTab', + duration, + logsCount: Object.keys(logs).length, + }); + } catch (error) { + logStore.logError('Failed to refresh logs', error, { + type: 'refresh_error', + message: 'Failed to refresh logs', + component: 'EventLogsTab', + }); + } finally { + setTimeout(() => setIsRefreshing(false), 500); + } + }, [logs]); + + // Log preference changes + const handlePreferenceChange = useCallback((type: string, value: boolean) => { + logStore.logInfo('Log preference changed', { + type: 'preference_change', + message: `Log preference "${type}" changed to ${value}`, + component: 'EventLogsTab', + preference: type, + value, + }); + + switch (type) { + case 'timestamps': + setShowTimestamps(value); + break; + case '24hour': + setUse24Hour(value); + break; + case 'autoExpand': + setAutoExpand(value); + break; + } + }, []); + + // Close filters when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (levelFilterRef.current && !levelFilterRef.current.contains(event.target as Node)) { + setShowLevelFilter(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const selectedLevelOption = logLevelOptions.find((opt) => opt.value === selectedLevel); + + // Export functions + const exportAsJSON = () => { + try { + const exportData = { + timestamp: new Date().toISOString(), + logs: filteredLogs, + filters: { + level: selectedLevel, + searchQuery, + }, + preferences: { + use24Hour, + showTimestamps, + autoExpand, + }, + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-event-logs-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Event logs exported successfully as JSON'); + } catch (error) { + console.error('Failed to export JSON:', error); + toast.error('Failed to export event logs as JSON'); + } + }; + + const exportAsCSV = () => { + try { + // Convert logs to CSV format + const headers = ['Timestamp', 'Level', 'Category', 'Message', 'Details']; + const csvData = [ + headers, + ...filteredLogs.map((log) => [ + new Date(log.timestamp).toISOString(), + log.level, + log.category || '', + log.message, + log.details ? JSON.stringify(log.details) : '', + ]), + ]; + + const csvContent = csvData + .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')) + .join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-event-logs-${new Date().toISOString()}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Event logs exported successfully as CSV'); + } catch (error) { + console.error('Failed to export CSV:', error); + toast.error('Failed to export event logs as CSV'); + } + }; + + const exportAsPDF = () => { + try { + // Create new PDF document + const doc = new jsPDF(); + const lineHeight = 7; + let yPos = 20; + const margin = 20; + const pageWidth = doc.internal.pageSize.getWidth(); + const maxLineWidth = pageWidth - 2 * margin; + + // Helper function to add section header + const addSectionHeader = (title: string) => { + // Check if we need a new page + if (yPos > doc.internal.pageSize.getHeight() - 30) { + doc.addPage(); + yPos = margin; + } + + doc.setFillColor('#F3F4F6'); + doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setTextColor('#111827'); + doc.setFontSize(12); + doc.text(title.toUpperCase(), margin, yPos); + yPos += lineHeight * 2; + }; + + // Add title and header + doc.setFillColor('#6366F1'); + doc.rect(0, 0, pageWidth, 50, 'F'); + doc.setTextColor('#FFFFFF'); + doc.setFontSize(24); + doc.setFont('helvetica', 'bold'); + doc.text('Event Logs Report', margin, 35); + + // Add subtitle with bolt.diy + doc.setFontSize(12); + doc.setFont('helvetica', 'normal'); + doc.text('bolt.diy - AI Development Platform', margin, 45); + yPos = 70; + + // Add report summary section + addSectionHeader('Report Summary'); + + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor('#374151'); + + const summaryItems = [ + { label: 'Generated', value: new Date().toLocaleString() }, + { label: 'Total Logs', value: filteredLogs.length.toString() }, + { label: 'Filter Applied', value: selectedLevel === 'all' ? 'All Types' : selectedLevel }, + { label: 'Search Query', value: searchQuery || 'None' }, + { label: 'Time Format', value: use24Hour ? '24-hour' : '12-hour' }, + ]; + + summaryItems.forEach((item) => { + doc.setFont('helvetica', 'bold'); + doc.text(`${item.label}:`, margin, yPos); + doc.setFont('helvetica', 'normal'); + doc.text(item.value, margin + 60, yPos); + yPos += lineHeight; + }); + + yPos += lineHeight * 2; + + // Add statistics section + addSectionHeader('Log Statistics'); + + // Calculate statistics + const stats = { + error: filteredLogs.filter((log) => log.level === 'error').length, + warning: filteredLogs.filter((log) => log.level === 'warning').length, + info: filteredLogs.filter((log) => log.level === 'info').length, + debug: filteredLogs.filter((log) => log.level === 'debug').length, + provider: filteredLogs.filter((log) => log.category === 'provider').length, + api: filteredLogs.filter((log) => log.category === 'api').length, + }; + + // Create two columns for statistics + const leftStats = [ + { label: 'Error Logs', value: stats.error, color: '#DC2626' }, + { label: 'Warning Logs', value: stats.warning, color: '#F59E0B' }, + { label: 'Info Logs', value: stats.info, color: '#3B82F6' }, + ]; + + const rightStats = [ + { label: 'Debug Logs', value: stats.debug, color: '#6B7280' }, + { label: 'LLM Logs', value: stats.provider, color: '#10B981' }, + { label: 'API Logs', value: stats.api, color: '#3B82F6' }, + ]; + + const colWidth = (pageWidth - 2 * margin) / 2; + + // Draw statistics in two columns + leftStats.forEach((stat, index) => { + doc.setTextColor(stat.color); + doc.setFont('helvetica', 'bold'); + doc.text(stat.value.toString(), margin, yPos); + doc.setTextColor('#374151'); + doc.setFont('helvetica', 'normal'); + doc.text(stat.label, margin + 20, yPos); + + if (rightStats[index]) { + doc.setTextColor(rightStats[index].color); + doc.setFont('helvetica', 'bold'); + doc.text(rightStats[index].value.toString(), margin + colWidth, yPos); + doc.setTextColor('#374151'); + doc.setFont('helvetica', 'normal'); + doc.text(rightStats[index].label, margin + colWidth + 20, yPos); + } + + yPos += lineHeight; + }); + + yPos += lineHeight * 2; + + // Add logs section + addSectionHeader('Event Logs'); + + // Helper function to add a log entry with improved formatting + const addLogEntry = (log: LogEntry) => { + const entryHeight = 20 + (log.details ? 40 : 0); // Estimate entry height + + // Check if we need a new page + if (yPos + entryHeight > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + } + + // Add timestamp and level + const timestamp = new Date(log.timestamp).toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: !use24Hour, + }); + + // Draw log level badge background + const levelColors: Record = { + error: '#FEE2E2', + warning: '#FEF3C7', + info: '#DBEAFE', + debug: '#F3F4F6', + }; + + const textColors: Record = { + error: '#DC2626', + warning: '#F59E0B', + info: '#3B82F6', + debug: '#6B7280', + }; + + const levelWidth = doc.getTextWidth(log.level.toUpperCase()) + 10; + doc.setFillColor(levelColors[log.level] || '#F3F4F6'); + doc.roundedRect(margin, yPos - 4, levelWidth, lineHeight + 4, 1, 1, 'F'); + + // Add log level text + doc.setTextColor(textColors[log.level] || '#6B7280'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(8); + doc.text(log.level.toUpperCase(), margin + 5, yPos); + + // Add timestamp + doc.setTextColor('#6B7280'); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + doc.text(timestamp, margin + levelWidth + 10, yPos); + + // Add category if present + if (log.category) { + const categoryX = margin + levelWidth + doc.getTextWidth(timestamp) + 20; + doc.setFillColor('#F3F4F6'); + + const categoryWidth = doc.getTextWidth(log.category) + 10; + doc.roundedRect(categoryX, yPos - 4, categoryWidth, lineHeight + 4, 2, 2, 'F'); + doc.setTextColor('#6B7280'); + doc.text(log.category, categoryX + 5, yPos); + } + + yPos += lineHeight * 1.5; + + // Add message + doc.setTextColor('#111827'); + doc.setFontSize(10); + + const messageLines = doc.splitTextToSize(log.message, maxLineWidth - 10); + doc.text(messageLines, margin + 5, yPos); + yPos += messageLines.length * lineHeight; + + // Add details if present + if (log.details) { + doc.setTextColor('#6B7280'); + doc.setFontSize(8); + + const detailsStr = JSON.stringify(log.details, null, 2); + const detailsLines = doc.splitTextToSize(detailsStr, maxLineWidth - 15); + + // Add details background + doc.setFillColor('#F9FAFB'); + doc.roundedRect(margin + 5, yPos - 2, maxLineWidth - 10, detailsLines.length * lineHeight + 8, 1, 1, 'F'); + + doc.text(detailsLines, margin + 10, yPos + 4); + yPos += detailsLines.length * lineHeight + 10; + } + + // Add separator line + doc.setDrawColor('#E5E7EB'); + doc.setLineWidth(0.1); + doc.line(margin, yPos, pageWidth - margin, yPos); + yPos += lineHeight * 1.5; + }; + + // Add all logs + filteredLogs.forEach((log) => { + addLogEntry(log); + }); + + // Add footer to all pages + const totalPages = doc.internal.pages.length - 1; + + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + doc.setFontSize(8); + doc.setTextColor('#9CA3AF'); + + // Add page numbers + doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, { + align: 'center', + }); + + // Add footer text + doc.text('Generated by bolt.diy', margin, doc.internal.pageSize.getHeight() - 10); + + const dateStr = new Date().toLocaleDateString(); + doc.text(dateStr, pageWidth - margin, doc.internal.pageSize.getHeight() - 10, { align: 'right' }); + } + + // Save the PDF + doc.save(`bolt-event-logs-${new Date().toISOString()}.pdf`); + toast.success('Event logs exported successfully as PDF'); + } catch (error) { + console.error('Failed to export PDF:', error); + toast.error('Failed to export event logs as PDF'); + } + }; + + const exportAsText = () => { + try { + const textContent = filteredLogs + .map((log) => { + const timestamp = new Date(log.timestamp).toLocaleString(); + let content = `[${timestamp}] ${log.level.toUpperCase()}: ${log.message}\n`; + + if (log.category) { + content += `Category: ${log.category}\n`; + } + + if (log.details) { + content += `Details:\n${JSON.stringify(log.details, null, 2)}\n`; + } + + return content + '-'.repeat(80) + '\n'; + }) + .join('\n'); + + const blob = new Blob([textContent], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-event-logs-${new Date().toISOString()}.txt`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Event logs exported successfully as text file'); + } catch (error) { + console.error('Failed to export text file:', error); + toast.error('Failed to export event logs as text file'); + } + }; + + const exportFormats: ExportFormat[] = [ + { + id: 'json', + label: 'Export as JSON', + icon: 'i-ph:file-json', + handler: exportAsJSON, + }, + { + id: 'csv', + label: 'Export as CSV', + icon: 'i-ph:file-csv', + handler: exportAsCSV, + }, + { + id: 'pdf', + label: 'Export as PDF', + icon: 'i-ph:file-pdf', + handler: exportAsPDF, + }, + { + id: 'txt', + label: 'Export as Text', + icon: 'i-ph:file-text', + handler: exportAsText, + }, + ]; + + const ExportButton = () => { + const [isOpen, setIsOpen] = useState(false); + + const handleOpenChange = useCallback((open: boolean) => { + setIsOpen(open); + }, []); + + const handleFormatClick = useCallback((handler: () => void) => { + handler(); + setIsOpen(false); + }, []); + + return ( + + + + +
+ +
+ Export Event Logs + + +
+ {exportFormats.map((format) => ( + + ))} +
+
+
+
+ ); + }; + + return ( +
+
+ + + + + + + + {logLevelOptions.map((option) => ( + handleLevelFilterChange(option.value)} + > +
+
+
+ {option.label} + + ))} + + + + +
+
+ handlePreferenceChange('timestamps', value)} + className="data-[state=checked]:bg-purple-500" + /> + Show Timestamps +
+ +
+ handlePreferenceChange('24hour', value)} + className="data-[state=checked]:bg-purple-500" + /> + 24h Time +
+ +
+ handlePreferenceChange('autoExpand', value)} + className="data-[state=checked]:bg-purple-500" + /> + Auto Expand +
+ +
+ + + + +
+
+ +
+
+ setSearchQuery(e.target.value)} + className={classNames( + 'w-full px-4 py-2 pl-10 rounded-lg', + 'bg-[#FAFAFA] dark:bg-[#0A0A0A]', + 'border border-[#E5E5E5] dark:border-[#1A1A1A]', + 'text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500', + 'transition-all duration-200', + )} + /> +
+
+
+
+ + {filteredLogs.length === 0 ? ( + + +
+

No Logs Found

+

Try adjusting your search or filters

+
+
+ ) : ( + filteredLogs.map((log) => ( + + )) + )} +
+
+ ); +} diff --git a/app/components/@settings/tabs/features/FeaturesTab.tsx b/app/components/@settings/tabs/features/FeaturesTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3b14a7565dd0de742d8a1278e1312bcc264f44ce --- /dev/null +++ b/app/components/@settings/tabs/features/FeaturesTab.tsx @@ -0,0 +1,295 @@ +// Remove unused imports +import React, { memo, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import { Switch } from '~/components/ui/Switch'; +import { useSettings } from '~/lib/hooks/useSettings'; +import { classNames } from '~/utils/classNames'; +import { toast } from 'react-toastify'; +import { PromptLibrary } from '~/lib/common/prompt-library'; + +interface FeatureToggle { + id: string; + title: string; + description: string; + icon: string; + enabled: boolean; + beta?: boolean; + experimental?: boolean; + tooltip?: string; +} + +const FeatureCard = memo( + ({ + feature, + index, + onToggle, + }: { + feature: FeatureToggle; + index: number; + onToggle: (id: string, enabled: boolean) => void; + }) => ( + +
+
+
+
+
+

{feature.title}

+ {feature.beta && ( + Beta + )} + {feature.experimental && ( + + Experimental + + )} +
+
+ onToggle(feature.id, checked)} /> +
+

{feature.description}

+ {feature.tooltip &&

{feature.tooltip}

} +
+ + ), +); + +const FeatureSection = memo( + ({ + title, + features, + icon, + description, + onToggleFeature, + }: { + title: string; + features: FeatureToggle[]; + icon: string; + description: string; + onToggleFeature: (id: string, enabled: boolean) => void; + }) => ( + +
+
+
+

{title}

+

{description}

+
+
+ +
+ {features.map((feature, index) => ( + + ))} +
+ + ), +); + +export default function FeaturesTab() { + const { + autoSelectTemplate, + isLatestBranch, + contextOptimizationEnabled, + eventLogs, + setAutoSelectTemplate, + enableLatestBranch, + enableContextOptimization, + setEventLogs, + setPromptId, + promptId, + } = useSettings(); + + // Enable features by default on first load + React.useEffect(() => { + // Only set defaults if values are undefined + if (isLatestBranch === undefined) { + enableLatestBranch(false); // Default: OFF - Don't auto-update from main branch + } + + if (contextOptimizationEnabled === undefined) { + enableContextOptimization(true); // Default: ON - Enable context optimization + } + + if (autoSelectTemplate === undefined) { + setAutoSelectTemplate(true); // Default: ON - Enable auto-select templates + } + + if (promptId === undefined) { + setPromptId('default'); // Default: 'default' + } + + if (eventLogs === undefined) { + setEventLogs(true); // Default: ON - Enable event logging + } + }, []); // Only run once on component mount + + const handleToggleFeature = useCallback( + (id: string, enabled: boolean) => { + switch (id) { + case 'latestBranch': { + enableLatestBranch(enabled); + toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`); + break; + } + + case 'autoSelectTemplate': { + setAutoSelectTemplate(enabled); + toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`); + break; + } + + case 'contextOptimization': { + enableContextOptimization(enabled); + toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`); + break; + } + + case 'eventLogs': { + setEventLogs(enabled); + toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`); + break; + } + + default: + break; + } + }, + [enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs], + ); + + const features = { + stable: [ + { + id: 'latestBranch', + title: 'Main Branch Updates', + description: 'Get the latest updates from the main branch', + icon: 'i-ph:git-branch', + enabled: isLatestBranch, + tooltip: 'Enabled by default to receive updates from the main development branch', + }, + { + id: 'autoSelectTemplate', + title: 'Auto Select Template', + description: 'Automatically select starter template', + icon: 'i-ph:selection', + enabled: autoSelectTemplate, + tooltip: 'Enabled by default to automatically select the most appropriate starter template', + }, + { + id: 'contextOptimization', + title: 'Context Optimization', + description: 'Optimize context for better responses', + icon: 'i-ph:brain', + enabled: contextOptimizationEnabled, + tooltip: 'Enabled by default for improved AI responses', + }, + { + id: 'eventLogs', + title: 'Event Logging', + description: 'Enable detailed event logging and history', + icon: 'i-ph:list-bullets', + enabled: eventLogs, + tooltip: 'Enabled by default to record detailed logs of system events and user actions', + }, + ], + beta: [], + }; + + return ( +
+ + + {features.beta.length > 0 && ( + + )} + + +
+
+
+
+
+

+ Prompt Library +

+

+ Choose a prompt from the library to use as the system prompt +

+
+ +
+ +
+ ); +} diff --git a/app/components/@settings/tabs/notifications/NotificationsTab.tsx b/app/components/@settings/tabs/notifications/NotificationsTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cb5f3da1c7de5a0bcaa4423f9a7334d7eaa05d57 --- /dev/null +++ b/app/components/@settings/tabs/notifications/NotificationsTab.tsx @@ -0,0 +1,300 @@ +import React, { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { logStore } from '~/lib/stores/logs'; +import { useStore } from '@nanostores/react'; +import { formatDistanceToNow } from 'date-fns'; +import { classNames } from '~/utils/classNames'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; + +interface NotificationDetails { + type?: string; + message?: string; + currentVersion?: string; + latestVersion?: string; + branch?: string; + updateUrl?: string; +} + +type FilterType = 'all' | 'system' | 'error' | 'warning' | 'update' | 'info' | 'provider' | 'network'; + +const NotificationsTab = () => { + const [filter, setFilter] = useState('all'); + const logs = useStore(logStore.logs); + + useEffect(() => { + const startTime = performance.now(); + + return () => { + const duration = performance.now() - startTime; + logStore.logPerformanceMetric('NotificationsTab', 'mount-duration', duration); + }; + }, []); + + const handleClearNotifications = () => { + const count = Object.keys(logs).length; + logStore.logInfo('Cleared notifications', { + type: 'notification_clear', + message: `Cleared ${count} notifications`, + clearedCount: count, + component: 'notifications', + }); + logStore.clearLogs(); + }; + + const handleUpdateAction = (updateUrl: string) => { + logStore.logInfo('Update link clicked', { + type: 'update_click', + message: 'User clicked update link', + updateUrl, + component: 'notifications', + }); + window.open(updateUrl, '_blank'); + }; + + const handleFilterChange = (newFilter: FilterType) => { + logStore.logInfo('Notification filter changed', { + type: 'filter_change', + message: `Filter changed to ${newFilter}`, + previousFilter: filter, + newFilter, + component: 'notifications', + }); + setFilter(newFilter); + }; + + const filteredLogs = Object.values(logs) + .filter((log) => { + if (filter === 'all') { + return true; + } + + if (filter === 'update') { + return log.details?.type === 'update'; + } + + if (filter === 'system') { + return log.category === 'system'; + } + + if (filter === 'provider') { + return log.category === 'provider'; + } + + if (filter === 'network') { + return log.category === 'network'; + } + + return log.level === filter; + }) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + const getNotificationStyle = (level: string, type?: string) => { + if (type === 'update') { + return { + icon: 'i-ph:arrow-circle-up', + color: 'text-purple-500 dark:text-purple-400', + bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20', + }; + } + + switch (level) { + case 'error': + return { + icon: 'i-ph:warning-circle', + color: 'text-red-500 dark:text-red-400', + bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20', + }; + case 'warning': + return { + icon: 'i-ph:warning', + color: 'text-yellow-500 dark:text-yellow-400', + bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20', + }; + case 'info': + return { + icon: 'i-ph:info', + color: 'text-blue-500 dark:text-blue-400', + bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20', + }; + default: + return { + icon: 'i-ph:bell', + color: 'text-gray-500 dark:text-gray-400', + bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20', + }; + } + }; + + const renderNotificationDetails = (details: NotificationDetails) => { + if (details.type === 'update') { + return ( +
+

{details.message}

+
+

Current Version: {details.currentVersion}

+

Latest Version: {details.latestVersion}

+

Branch: {details.branch}

+
+ +
+ ); + } + + return details.message ?

{details.message}

: null; + }; + + const filterOptions: { id: FilterType; label: string; icon: string; color: string }[] = [ + { id: 'all', label: 'All Notifications', icon: 'i-ph:bell', color: '#9333ea' }, + { id: 'system', label: 'System', icon: 'i-ph:gear', color: '#6b7280' }, + { id: 'update', label: 'Updates', icon: 'i-ph:arrow-circle-up', color: '#9333ea' }, + { id: 'error', label: 'Errors', icon: 'i-ph:warning-circle', color: '#ef4444' }, + { id: 'warning', label: 'Warnings', icon: 'i-ph:warning', color: '#f59e0b' }, + { id: 'info', label: 'Information', icon: 'i-ph:info', color: '#3b82f6' }, + { id: 'provider', label: 'Providers', icon: 'i-ph:robot', color: '#10b981' }, + { id: 'network', label: 'Network', icon: 'i-ph:wifi-high', color: '#6366f1' }, + ]; + + return ( +
+
+ + + + + + + + {filterOptions.map((option) => ( + handleFilterChange(option.id)} + > +
+
+
+ {option.label} + + ))} + + + + + +
+ +
+ {filteredLogs.length === 0 ? ( + + +
+

No Notifications

+

You're all caught up!

+
+
+ ) : ( + filteredLogs.map((log) => { + const style = getNotificationStyle(log.level, log.details?.type); + return ( + +
+
+ +
+

{log.message}

+ {log.details && renderNotificationDetails(log.details as NotificationDetails)} +

+ Category: {log.category} + {log.subCategory ? ` > ${log.subCategory}` : ''} +

+
+
+ +
+
+ ); + }) + )} +
+
+ ); +}; + +export default NotificationsTab; diff --git a/app/components/@settings/tabs/profile/ProfileTab.tsx b/app/components/@settings/tabs/profile/ProfileTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6783a9072d740f4fcbfdc99015a3f521e30375f1 --- /dev/null +++ b/app/components/@settings/tabs/profile/ProfileTab.tsx @@ -0,0 +1,181 @@ +import { useState, useCallback } from 'react'; +import { useStore } from '@nanostores/react'; +import { classNames } from '~/utils/classNames'; +import { profileStore, updateProfile } from '~/lib/stores/profile'; +import { toast } from 'react-toastify'; +import { debounce } from '~/utils/debounce'; + +export default function ProfileTab() { + const profile = useStore(profileStore); + const [isUploading, setIsUploading] = useState(false); + + // Create debounced update functions + const debouncedUpdate = useCallback( + debounce((field: 'username' | 'bio', value: string) => { + updateProfile({ [field]: value }); + toast.success(`${field.charAt(0).toUpperCase() + field.slice(1)} updated`); + }, 1000), + [], + ); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (!file) { + return; + } + + try { + setIsUploading(true); + + // Convert the file to base64 + const reader = new FileReader(); + + reader.onloadend = () => { + const base64String = reader.result as string; + updateProfile({ avatar: base64String }); + setIsUploading(false); + toast.success('Profile picture updated'); + }; + + reader.onerror = () => { + console.error('Error reading file:', reader.error); + setIsUploading(false); + toast.error('Failed to update profile picture'); + }; + reader.readAsDataURL(file); + } catch (error) { + console.error('Error uploading avatar:', error); + setIsUploading(false); + toast.error('Failed to update profile picture'); + } + }; + + const handleProfileUpdate = (field: 'username' | 'bio', value: string) => { + // Update the store immediately for UI responsiveness + updateProfile({ [field]: value }); + + // Debounce the toast notification + debouncedUpdate(field, value); + }; + + return ( +
+
+ {/* Personal Information Section */} +
+ {/* Avatar Upload */} +
+
+ {profile.avatar ? ( + Profile + ) : ( +
+ )} + +