ui refactor

This commit is contained in:
Stijnus 2025-01-20 09:53:15 +01:00
parent 9230ef3b55
commit 436a8e54bf
41 changed files with 4749 additions and 1964 deletions

View File

@ -1,300 +0,0 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
import { classNames } from '~/utils/classNames';
import { DialogTitle } from '~/components/ui/Dialog';
import type { SettingCategory, TabType } from './settings.types';
import { categoryLabels, categoryIcons } from './settings.types';
import ProfileTab from './profile/ProfileTab';
import ProvidersTab from './providers/ProvidersTab';
import { useSettings } from '~/lib/hooks/useSettings';
import FeaturesTab from './features/FeaturesTab';
import DebugTab from './debug/DebugTab';
import EventLogsTab from './event-logs/EventLogsTab';
import ConnectionsTab from './connections/ConnectionsTab';
import DataTab from './data/DataTab';
interface SettingsProps {
open: boolean;
onClose: () => void;
}
export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
const { debug, eventLogs } = useSettings();
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const settingItems = [
{
id: 'profile' as const,
label: 'Profile Settings',
icon: 'i-ph:user-circle',
category: 'profile' as const,
description: 'Manage your personal information and preferences',
component: () => <ProfileTab />,
keywords: ['profile', 'account', 'avatar', 'email', 'name', 'theme', 'notifications'],
},
{
id: 'data' as const,
label: 'Data Management',
icon: 'i-ph:database',
category: 'file_sharing' as const,
description: 'Manage your chat history and application data',
component: () => <DataTab />,
keywords: ['data', 'export', 'import', 'backup', 'delete'],
},
{
id: 'providers' as const,
label: 'Providers',
icon: 'i-ph:key',
category: 'file_sharing' as const,
description: 'Configure AI providers and API keys',
component: () => <ProvidersTab />,
keywords: ['api', 'keys', 'providers', 'configuration'],
},
{
id: 'connection' as const,
label: 'Connection',
icon: 'i-ph:link',
category: 'connectivity' as const,
description: 'Manage network and connection settings',
component: () => <ConnectionsTab />,
keywords: ['network', 'connection', 'proxy', 'ssl'],
},
{
id: 'features' as const,
label: 'Features',
icon: 'i-ph:star',
category: 'system' as const,
description: 'Configure application features and preferences',
component: () => <FeaturesTab />,
keywords: ['features', 'settings', 'options'],
},
] as const;
const debugItems = debug
? [
{
id: 'debug' as const,
label: 'Debug',
icon: 'i-ph:bug',
category: 'system' as const,
description: 'Advanced debugging tools and options',
component: () => <DebugTab />,
keywords: ['debug', 'logs', 'developer'],
},
]
: [];
const eventLogItems = eventLogs
? [
{
id: 'event-logs' as const,
label: 'Event Logs',
icon: 'i-ph:list-bullets',
category: 'system' as const,
description: 'View system events and application logs',
component: () => <EventLogsTab />,
keywords: ['logs', 'events', 'history'],
},
]
: [];
const allSettingItems = [...settingItems, ...debugItems, ...eventLogItems];
const filteredItems = allSettingItems.filter(
(item) =>
item.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.keywords?.some((keyword) => keyword.toLowerCase().includes(searchQuery.toLowerCase())),
);
const groupedItems = filteredItems.reduce(
(acc, item) => {
if (!acc[item.category]) {
acc[item.category] = allSettingItems.filter((i) => i.category === item.category);
}
return acc;
},
{} as Record<SettingCategory, typeof allSettingItems>,
);
const handleBackToDashboard = () => {
setActiveTab(null);
onClose();
};
const activeTabItem = allSettingItems.find((item) => item.id === activeTab);
return (
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<RadixDialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</RadixDialog.Overlay>
<RadixDialog.Content aria-describedby={undefined} asChild>
<motion.div
className={classNames(
'relative',
'w-[1000px] max-h-[90vh] min-h-[700px]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-2xl overflow-hidden shadow-2xl',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent',
)}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<AnimatePresence mode="wait">
{activeTab ? (
<motion.div
className="flex flex-col h-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between p-6 border-b border-[#E5E5E5] dark:border-[#1A1A1A] sticky top-0 bg-[#FAFAFA] dark:bg-[#0A0A0A] z-10">
<div className="flex items-center">
<button
onClick={() => setActiveTab(null)}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white"
>
<div className="i-ph:arrow-left w-4 h-4" />
Back to Settings
</button>
<div className="text-bolt-elements-textTertiary mx-6 select-none">|</div>
{activeTabItem && (
<div className="flex items-center gap-4">
<div className={classNames(activeTabItem.icon, 'w-6 h-6 text-purple-500')} />
<div>
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">
{activeTabItem.label}
</h2>
<p className="text-sm text-bolt-elements-textSecondary">{activeTabItem.description}</p>
</div>
</div>
)}
</div>
<button
onClick={handleBackToDashboard}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white"
>
<div className="i-ph:house w-4 h-4" />
Back to Bolt DIY
</button>
</div>
<div className="flex-1 p-6 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent">
{allSettingItems.find((item) => item.id === activeTab)?.component()}
</div>
</motion.div>
) : (
<motion.div
className="flex flex-col h-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between p-6 border-b border-[#E5E5E5] dark:border-[#1A1A1A] sticky top-0 bg-[#FAFAFA] dark:bg-[#0A0A0A] z-10">
<div className="flex items-center gap-3">
<div className="i-ph:lightning-fill w-5 h-5 text-purple-500" />
<DialogTitle className="text-lg font-medium text-bolt-elements-textPrimary">
Bolt Control Panel
</DialogTitle>
</div>
<div className="flex items-center gap-4">
<div className="relative w-[320px]">
<input
type="text"
placeholder="Search settings..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={classNames(
'w-full h-10 pl-10 pr-4 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 transition-all',
)}
/>
<div className="absolute left-3.5 top-1/2 -translate-y-1/2">
<div className="i-ph:magnifying-glass w-4 h-4 text-bolt-elements-textTertiary" />
</div>
</div>
<button
onClick={handleBackToDashboard}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white"
>
<div className="i-ph:house w-4 h-4" />
Back to Bolt DIY
</button>
</div>
</div>
<div className="flex-1 p-6 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent">
<div className="space-y-8">
{(Object.keys(groupedItems) as SettingCategory[]).map((category) => (
<div key={category} className="space-y-4">
<div className="flex items-center gap-3">
<div className={classNames(categoryIcons[category], 'w-5 h-5 text-purple-500')} />
<h2 className="text-base font-medium text-bolt-elements-textPrimary">
{categoryLabels[category]}
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{groupedItems[category].map((item) => (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={classNames(
'flex flex-col gap-2 p-4 rounded-lg text-left',
'bg-white dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'hover:bg-[#F8F8F8] dark:hover:bg-[#1A1A1A]',
'transition-all duration-200',
)}
>
<div className="flex items-center gap-3">
<div className={classNames(item.icon, 'w-5 h-5 text-purple-500')} />
<span className="text-sm font-medium text-bolt-elements-textPrimary">
{item.label}
</span>
</div>
{item.description && (
<p className="text-sm text-bolt-elements-textSecondary">{item.description}</p>
)}
</button>
))}
</div>
</div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</RadixDialog.Content>
</div>
</RadixDialog.Portal>
</RadixDialog.Root>
);
};

View File

@ -185,11 +185,15 @@ export default function DataTab() {
localStorage.removeItem('bolt_settings');
localStorage.removeItem('bolt_chat_history');
// Reload the page to apply reset
// 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);
@ -202,9 +206,15 @@ export default function DataTab() {
try {
// Clear chat history
localStorage.removeItem('bolt_chat_history');
// 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);
@ -216,7 +226,7 @@ export default function DataTab() {
<input ref={fileInputRef} type="file" accept=".json" onChange={handleImportSettings} className="hidden" />
{/* Reset Settings Dialog */}
<DialogRoot open={showResetInlineConfirm} onOpenChange={setShowResetInlineConfirm}>
<Dialog showCloseButton={false}>
<Dialog showCloseButton={false} className="z-[1000]">
<div className="p-6">
<div className="flex items-center gap-3">
<div className="i-ph:warning-circle-fill w-5 h-5 text-yellow-500" />
@ -252,7 +262,7 @@ export default function DataTab() {
{/* Delete Confirmation Dialog */}
<DialogRoot open={showDeleteInlineConfirm} onOpenChange={setShowDeleteInlineConfirm}>
<Dialog showCloseButton={false}>
<Dialog showCloseButton={false} className="z-[1000]">
<div className="p-6">
<div className="flex items-center gap-3">
<div className="i-ph:warning-circle-fill w-5 h-5 text-red-500" />

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,378 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { classNames } from '~/utils/classNames';
import { TabManagement } from './TabManagement';
import { TabTile } from '~/components/settings/shared/TabTile';
import type { TabType, TabVisibilityConfig } from '~/components/settings/settings.types';
import { tabConfigurationStore, updateTabConfiguration } from '~/lib/stores/settings';
import { useStore } from '@nanostores/react';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import DebugTab from '~/components/settings/debug/DebugTab';
import { EventLogsTab } from '~/components/settings/event-logs/EventLogsTab';
import UpdateTab from '~/components/settings/update/UpdateTab';
import { ProvidersTab } from '~/components/settings/providers/ProvidersTab';
import DataTab from '~/components/settings/data/DataTab';
import FeaturesTab from '~/components/settings/features/FeaturesTab';
import NotificationsTab from '~/components/settings/notifications/NotificationsTab';
import SettingsTab from '~/components/settings/settings/SettingsTab';
import ProfileTab from '~/components/settings/profile/ProfileTab';
import ConnectionsTab from '~/components/settings/connections/ConnectionsTab';
import { useUpdateCheck, useFeatures, useNotifications, useConnectionStatus, useDebugStatus } from '~/lib/hooks';
interface DraggableTabTileProps {
tab: TabVisibilityConfig;
index: number;
moveTab: (dragIndex: number, hoverIndex: number) => void;
onClick: () => void;
isActive: boolean;
hasUpdate: boolean;
statusMessage: string;
description: string;
isLoading?: boolean;
}
const TAB_DESCRIPTIONS: Record<TabType, string> = {
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',
providers: 'Configure AI providers and models',
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',
};
const DraggableTabTile = ({
tab,
index,
moveTab,
onClick,
isActive,
hasUpdate,
statusMessage,
description,
isLoading,
}: DraggableTabTileProps) => {
const [{ isDragging }, drag] = useDrag({
type: 'tab',
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [, drop] = useDrop({
accept: 'tab',
hover: (item: { index: number }) => {
if (item.index === index) {
return;
}
moveTab(item.index, index);
item.index = index;
},
});
return (
<div ref={(node) => drag(drop(node))} style={{ opacity: isDragging ? 0.5 : 1 }}>
<TabTile
tab={tab}
onClick={onClick}
isActive={isActive}
hasUpdate={hasUpdate}
statusMessage={statusMessage}
description={description}
isLoading={isLoading}
/>
</div>
);
};
interface DeveloperWindowProps {
open: boolean;
onClose: () => void;
}
export const DeveloperWindow = ({ open, onClose }: DeveloperWindowProps) => {
const tabConfiguration = useStore(tabConfigurationStore);
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const [showTabManagement, setShowTabManagement] = useState(false);
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
// 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();
const handleBack = () => {
if (showTabManagement) {
setShowTabManagement(false);
} else if (activeTab) {
setActiveTab(null);
}
};
// Only show tabs that are assigned to the developer window AND are visible
const visibleDeveloperTabs = tabConfiguration.developerTabs
.filter((tab: TabVisibilityConfig) => tab.window === 'developer' && tab.visible)
.sort((a: TabVisibilityConfig, b: TabVisibilityConfig) => (a.order || 0) - (b.order || 0));
const moveTab = (dragIndex: number, hoverIndex: number) => {
const draggedTab = visibleDeveloperTabs[dragIndex];
const targetTab = visibleDeveloperTabs[hoverIndex];
// Update the order of the dragged and target tabs
const updatedDraggedTab = { ...draggedTab, order: targetTab.order };
const updatedTargetTab = { ...targetTab, order: draggedTab.order };
// Update both tabs in the store
updateTabConfiguration(updatedDraggedTab);
updateTabConfiguration(updatedTargetTab);
};
const handleTabClick = async (tabId: TabType) => {
setLoadingTab(tabId);
setActiveTab(tabId);
// Acknowledge the status based on tab type
switch (tabId) {
case 'update':
await acknowledgeUpdate();
break;
case 'features':
await acknowledgeAllFeatures();
break;
case 'notifications':
await markAllAsRead();
break;
case 'connection':
acknowledgeIssue();
break;
case 'debug':
await acknowledgeAllIssues();
break;
}
// Simulate loading time (remove this in production)
await new Promise((resolve) => setTimeout(resolve, 1000));
setLoadingTab(null);
};
const getTabComponent = () => {
switch (activeTab) {
case 'profile':
return <ProfileTab />;
case 'settings':
return <SettingsTab />;
case 'notifications':
return <NotificationsTab />;
case 'features':
return <FeaturesTab />;
case 'data':
return <DataTab />;
case 'providers':
return <ProvidersTab />;
case 'connection':
return <ConnectionsTab />;
case 'debug':
return <DebugTab />;
case 'event-logs':
return <EventLogsTab />;
case 'update':
return <UpdateTab />;
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 '';
}
};
return (
<DndProvider backend={HTML5Backend}>
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[60]">
<RadixDialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</RadixDialog.Overlay>
<RadixDialog.Content aria-describedby={undefined} asChild>
<motion.div
className={classNames(
'relative',
'w-[1200px] h-[90vh]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-2xl shadow-2xl',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'flex flex-col overflow-hidden',
'z-[61]',
)}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
{/* Header */}
<div className="flex-none flex items-center justify-between px-6 py-4 border-b border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center gap-4">
{(activeTab || showTabManagement) && (
<motion.button
onClick={handleBack}
className={classNames(
'flex items-center justify-center w-8 h-8 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
'group transition-all duration-200',
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<div className="i-ph:arrow-left w-4 h-4 text-bolt-elements-textSecondary group-hover:text-purple-500 transition-colors" />
</motion.button>
)}
<div className="flex items-center gap-3">
<motion.div
className="i-ph:code-fill w-5 h-5 text-purple-500"
initial={{ rotate: 0 }}
animate={{ rotate: 360 }}
transition={{
repeat: Infinity,
duration: 8,
ease: 'linear',
}}
/>
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">
{showTabManagement ? 'Tab Management' : activeTab ? 'Developer Tools' : 'Developer Dashboard'}
</h2>
</div>
</div>
<div className="flex items-center gap-3">
{!showTabManagement && !activeTab && (
<motion.button
onClick={() => setShowTabManagement(true)}
className={classNames(
'px-3 py-1.5 rounded-lg text-sm',
'bg-purple-500/10 text-purple-500',
'hover:bg-purple-500/20',
'transition-colors duration-200',
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Manage Tabs
</motion.button>
)}
<motion.button
onClick={onClose}
className={classNames(
'flex items-center justify-center w-8 h-8 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
'group transition-all duration-200',
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<div className="i-ph:x w-4 h-4 text-bolt-elements-textSecondary group-hover:text-purple-500 transition-colors" />
</motion.button>
</div>
</div>
{/* Content */}
<div
className={classNames(
'flex-1',
'overflow-y-auto',
'hover:overflow-y-auto',
'scrollbar scrollbar-w-2',
'scrollbar-track-transparent',
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
'will-change-scroll',
'touch-auto',
)}
>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="p-6">
{showTabManagement ? (
<TabManagement />
) : activeTab ? (
getTabComponent()
) : (
<div className="grid grid-cols-4 gap-4">
{visibleDeveloperTabs.map((tab: TabVisibilityConfig, index: number) => (
<DraggableTabTile
key={tab.id}
tab={tab}
index={index}
moveTab={moveTab}
onClick={() => handleTabClick(tab.id)}
isActive={activeTab === tab.id}
hasUpdate={getTabUpdateStatus(tab.id)}
statusMessage={getStatusMessage(tab.id)}
description={TAB_DESCRIPTIONS[tab.id]}
isLoading={loadingTab === tab.id}
/>
))}
</div>
)}
</motion.div>
</div>
</motion.div>
</RadixDialog.Content>
</div>
</RadixDialog.Portal>
</RadixDialog.Root>
</DndProvider>
);
};

View File

@ -0,0 +1,315 @@
import { motion } from 'framer-motion';
import { useState } from 'react';
import { classNames } from '~/utils/classNames';
import { tabConfigurationStore, updateTabConfiguration, resetTabConfiguration } from '~/lib/stores/settings';
import { useStore } from '@nanostores/react';
import { TAB_LABELS, type TabType, type TabVisibilityConfig } from '~/components/settings/settings.types';
import { toast } from 'react-toastify';
// Define icons for each tab type
const TAB_ICONS: Record<TabType, string> = {
profile: 'i-ph:user-circle-fill',
settings: 'i-ph:gear-six-fill',
notifications: 'i-ph:bell-fill',
features: 'i-ph:sparkle-fill',
data: 'i-ph:database-fill',
providers: 'i-ph:robot-fill',
connection: 'i-ph:plug-fill',
debug: 'i-ph:bug-fill',
'event-logs': 'i-ph:list-bullets-fill',
update: 'i-ph:arrow-clockwise-fill',
};
interface TabGroupProps {
title: string;
description?: string;
tabs: TabVisibilityConfig[];
onVisibilityChange: (tabId: TabType, enabled: boolean) => void;
targetWindow: 'user' | 'developer';
standardTabs: TabType[];
}
const TabGroup = ({ title, description, tabs, onVisibilityChange, targetWindow }: TabGroupProps) => {
// Split tabs into visible and hidden
const visibleTabs = tabs.filter((tab) => tab.visible).sort((a, b) => (a.order || 0) - (b.order || 0));
const hiddenTabs = tabs.filter((tab) => !tab.visible).sort((a, b) => (a.order || 0) - (b.order || 0));
return (
<div className="mb-8 rounded-xl bg-white/5 p-6 backdrop-blur-sm dark:bg-gray-800/30">
<div className="mb-6">
<h3 className="flex items-center gap-2 text-lg font-medium text-gray-900 dark:text-white">
<span className="i-ph:layout-fill h-5 w-5 text-purple-500" />
{title}
</h3>
{description && <p className="mt-1.5 text-sm text-gray-600 dark:text-gray-400">{description}</p>}
</div>
<div className="space-y-6">
<motion.div layout className="space-y-2">
{visibleTabs.map((tab) => (
<motion.div
key={tab.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className="group relative flex items-center justify-between rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm transition-all hover:border-purple-200 hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:hover:border-purple-500/30"
>
<div className="flex items-center space-x-3">
<div
className={classNames(
TAB_ICONS[tab.id],
'h-5 w-5 transition-colors',
tab.id === 'profile'
? 'text-purple-500 dark:text-purple-400'
: 'text-gray-500 group-hover:text-purple-500 dark:text-gray-400 dark:group-hover:text-purple-400',
)}
/>
<span
className={classNames(
'text-sm font-medium transition-colors',
tab.id === 'profile'
? 'text-gray-900 dark:text-white'
: 'text-gray-700 group-hover:text-gray-900 dark:text-gray-300 dark:group-hover:text-white',
)}
>
{TAB_LABELS[tab.id]}
</span>
{tab.id === 'profile' && targetWindow === 'user' && (
<span className="rounded-full bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600 dark:bg-purple-500/10 dark:text-purple-400">
Standard
</span>
)}
</div>
<div className="flex items-center space-x-4">
{targetWindow === 'user' ? (
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={tab.visible}
onChange={(e) => onVisibilityChange(tab.id, e.target.checked)}
className="peer sr-only"
/>
<div
className={classNames(
'h-6 w-11 rounded-full bg-gray-200 transition-colors dark:bg-gray-700',
'after:absolute after:left-[2px] after:top-[2px]',
'after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-sm',
'after:transition-all after:content-[""]',
'peer-checked:bg-purple-500 peer-checked:after:translate-x-full',
'peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-500/20',
)}
/>
</label>
) : (
<div className="text-sm text-gray-500 dark:text-gray-400">Always visible</div>
)}
</div>
</motion.div>
))}
</motion.div>
{hiddenTabs.length > 0 && (
<motion.div layout className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<span className="i-ph:eye-slash-fill h-4 w-4" />
Hidden Tabs
</div>
{hiddenTabs.map((tab) => (
<motion.div
key={tab.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className="group relative flex items-center justify-between rounded-lg border border-gray-200 bg-white/50 px-4 py-3 transition-all hover:border-purple-200 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:border-purple-500/30"
>
<div className="flex items-center space-x-3">
<div
className={classNames(
TAB_ICONS[tab.id],
'h-5 w-5 transition-colors',
'text-gray-400 group-hover:text-purple-500 dark:text-gray-500 dark:group-hover:text-purple-400',
)}
/>
<span className="text-sm font-medium text-gray-500 transition-colors group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white">
{TAB_LABELS[tab.id]}
</span>
</div>
<div className="flex items-center space-x-4">
{targetWindow === 'user' && (
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={tab.visible}
onChange={(e) => onVisibilityChange(tab.id, e.target.checked)}
className="peer sr-only"
/>
<div
className={classNames(
'h-6 w-11 rounded-full bg-gray-200 transition-colors dark:bg-gray-700',
'after:absolute after:left-[2px] after:top-[2px]',
'after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-sm',
'after:transition-all after:content-[""]',
'peer-checked:bg-purple-500 peer-checked:after:translate-x-full',
'peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-500/20',
)}
/>
</label>
)}
</div>
</motion.div>
))}
</motion.div>
)}
</div>
</div>
);
};
export const TabManagement = () => {
const config = useStore(tabConfigurationStore);
const [searchQuery, setSearchQuery] = useState('');
// Define standard (visible by default) tabs for each window
const standardUserTabs: TabType[] = ['features', 'data', 'providers', 'connection', 'debug'];
const standardDeveloperTabs: TabType[] = [
'profile',
'settings',
'notifications',
'features',
'data',
'providers',
'connection',
'debug',
'event-logs',
'update',
];
const handleVisibilityChange = (tabId: TabType, enabled: boolean, targetWindow: 'user' | 'developer') => {
const tabs = targetWindow === 'user' ? config.userTabs : config.developerTabs;
const existingTab = tabs.find((tab) => tab.id === tabId);
const updatedTab: TabVisibilityConfig = existingTab
? {
...existingTab,
visible: enabled,
}
: {
id: tabId,
visible: enabled,
window: targetWindow,
order: tabs.length,
};
// Update the store
updateTabConfiguration(updatedTab);
// Show toast notification
toast.success(`${TAB_LABELS[tabId]} ${enabled ? 'enabled' : 'disabled'} in ${targetWindow} window`);
};
const handleResetToDefaults = () => {
resetTabConfiguration();
toast.success('Tab settings reset to defaults');
};
// Filter tabs based on search and window
const userTabs = config.userTabs.filter((tab) =>
TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase()),
);
const developerTabs = config.developerTabs.filter((tab) =>
TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<div className="h-full overflow-y-auto px-6 py-6">
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h2 className="flex items-center gap-2 text-xl font-semibold text-gray-900 dark:text-white">
<span className="i-ph:squares-four-fill h-6 w-6 text-purple-500" />
Tab Management
</h2>
<p className="mt-1.5 text-sm text-gray-600 dark:text-gray-400">
Configure which tabs are visible in the user and developer windows
</p>
</div>
<button
onClick={handleResetToDefaults}
className="inline-flex items-center gap-1.5 rounded-lg bg-purple-50 px-4 py-2 text-sm font-medium text-purple-600 transition-colors hover:bg-purple-100 focus:outline-none focus:ring-4 focus:ring-purple-500/20 dark:bg-purple-500/10 dark:text-purple-400 dark:hover:bg-purple-500/20"
>
<span className="i-ph:arrow-counter-clockwise-fill h-4 w-4" />
Reset to Defaults
</button>
</div>
<div className="mt-6 flex items-center gap-4">
<div className="relative flex-1">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span className="i-ph:magnifying-glass h-5 w-5 text-gray-400" />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tabs..."
className="block w-full rounded-lg border border-gray-200 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder:text-gray-500 focus:border-purple-500 focus:outline-none focus:ring-4 focus:ring-purple-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-purple-400"
/>
</div>
</div>
</div>
<div className="space-y-8">
{/* User Window Section */}
<div className="rounded-xl border border-purple-100 bg-purple-50/50 p-1 dark:border-purple-500/10 dark:bg-purple-500/5">
<div className="rounded-lg bg-white p-6 dark:bg-gray-800">
<div className="mb-6 flex items-center gap-3">
<div className="rounded-lg bg-purple-100 p-2 dark:bg-purple-500/10">
<span className="i-ph:user-circle-fill h-5 w-5 text-purple-500 dark:text-purple-400" />
</div>
<div>
<h3 className="text-base font-medium text-gray-900 dark:text-white">User Window</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Configure tabs visible to regular users</p>
</div>
</div>
<TabGroup
title="User Interface"
description="Manage which tabs are visible in the user window"
tabs={userTabs}
onVisibilityChange={(tabId, enabled) => handleVisibilityChange(tabId, enabled, 'user')}
targetWindow="user"
standardTabs={standardUserTabs}
/>
</div>
</div>
{/* Developer Window Section */}
<div className="rounded-xl border border-blue-100 bg-blue-50/50 p-1 dark:border-blue-500/10 dark:bg-blue-500/5">
<div className="rounded-lg bg-white p-6 dark:bg-gray-800">
<div className="mb-6 flex items-center gap-3">
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-500/10">
<span className="i-ph:code-fill h-5 w-5 text-blue-500 dark:text-blue-400" />
</div>
<div>
<h3 className="text-base font-medium text-gray-900 dark:text-white">Developer Window</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Configure tabs visible to developers</p>
</div>
</div>
<TabGroup
title="Developer Interface"
description="Manage which tabs are visible in the developer window"
tabs={developerTabs}
onVisibilityChange={(tabId, enabled) => handleVisibilityChange(tabId, enabled, 'developer')}
targetWindow="developer"
standardTabs={standardDeveloperTabs}
/>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,151 +1,304 @@
import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react';
import { useSettings } from '~/lib/hooks/useSettings';
import { toast } from 'react-toastify';
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 { motion } from 'framer-motion';
import { settingsStyles } from '~/components/settings/settings.styles';
export default function EventLogsTab() {
const {} = useSettings();
const showLogs = useStore(logStore.showLogs);
interface SelectOption {
value: string;
label: string;
icon: string;
color?: string;
}
const logLevelOptions: SelectOption[] = [
{ value: 'all', label: 'All Levels', icon: 'i-ph:funnel' },
{ value: 'info', label: 'Info', icon: 'i-ph:info', color: 'text-blue-500' },
{ value: 'warning', label: 'Warning', icon: 'i-ph:warning', color: 'text-yellow-500' },
{ value: 'error', label: 'Error', icon: 'i-ph:x-circle', color: 'text-red-500' },
{ value: 'debug', label: 'Debug', icon: 'i-ph:bug', color: 'text-gray-500' },
];
const logCategoryOptions: SelectOption[] = [
{ value: 'all', label: 'All Categories', icon: 'i-ph:squares-four' },
{ value: 'system', label: 'System', icon: 'i-ph:desktop' },
{ value: 'provider', label: 'Provider', icon: 'i-ph:plug' },
{ value: 'user', label: 'User', icon: 'i-ph:user' },
{ value: 'error', label: 'Error', icon: 'i-ph:warning-octagon' },
];
const SegmentedGroup = ({
value,
onChange,
options,
className,
}: {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
className?: string;
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const selectedOption = options.find((opt) => opt.value === value);
if (!isExpanded) {
return (
<button
type="button"
onClick={() => setIsExpanded(true)}
className={classNames(
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm',
'bg-white/50 dark:bg-gray-800/30',
'hover:bg-gray-50 dark:hover:bg-gray-800/50',
'border border-gray-200/50 dark:border-gray-700/50',
'text-bolt-elements-textPrimary',
className,
)}
>
<div className={classNames(selectedOption?.icon, 'text-base text-purple-500')} />
<span className="text-sm">{selectedOption?.label}</span>
<div className="i-ph:caret-right text-sm text-bolt-elements-textTertiary" />
</button>
);
}
return (
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-white/50 dark:bg-gray-800/30 border border-gray-200/50 dark:border-gray-700/50">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value);
setIsExpanded(false);
}}
className={classNames(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors',
option.value === value
? 'bg-purple-100 dark:bg-purple-800/40 text-purple-900 dark:text-purple-200'
: 'text-bolt-elements-textSecondary hover:bg-gray-50 dark:hover:bg-gray-800/50',
)}
>
<div className={classNames(option.icon, 'text-base', option.value === value ? option.color : '')} />
<span className="truncate">{option.label}</span>
</button>
))}
</div>
);
};
const LogEntryItem = ({
log,
isExpanded: forceExpanded,
use24Hour,
}: {
log: LogEntry;
isExpanded: boolean;
use24Hour: boolean;
}) => {
const [isExpanded, setIsExpanded] = useState(forceExpanded);
const [isCopied, setIsCopied] = useState(false);
useEffect(() => {
setIsExpanded(forceExpanded);
}, [forceExpanded]);
const handleCopy = useCallback(() => {
const logText = `[${log.level.toUpperCase()}] ${log.message}\nTimestamp: ${new Date(
log.timestamp,
).toLocaleString()}\nCategory: ${log.category}\nDetails: ${JSON.stringify(log.details, null, 2)}`;
navigator.clipboard.writeText(logText).then(() => {
setIsCopied(true);
toast.success('Log copied to clipboard');
setTimeout(() => setIsCopied(false), 2000);
});
}, [log]);
const formattedTime = useMemo(() => {
const date = new Date(log.timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const isYesterday = new Date(now.setDate(now.getDate() - 1)).toDateString() === date.toDateString();
const timeStr = date.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: !use24Hour,
});
if (isToday) {
return {
primary: timeStr,
secondary: 'Today',
};
} else if (isYesterday) {
return {
primary: timeStr,
secondary: 'Yesterday',
};
} else {
const dateStr = date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
return {
primary: dateStr,
secondary: timeStr,
};
}
}, [log.timestamp, use24Hour]);
return (
<div
className={classNames('group transition-colors', 'hover:bg-gray-50 dark:hover:bg-gray-800/50', 'py-3', {
'bg-red-50/20 dark:bg-red-900/5': log.level === 'error',
'bg-yellow-50/20 dark:bg-yellow-900/5': log.level === 'warning',
'bg-blue-50/20 dark:bg-blue-900/5': log.level === 'info',
'bg-gray-50/20 dark:bg-gray-800/5': log.level === 'debug',
})}
>
<div className="px-3">
<div className="flex items-center gap-3">
<span
className={classNames('px-2 py-0.5 text-xs font-medium rounded-full', {
'bg-red-100/80 text-red-800 dark:bg-red-500/10 dark:text-red-400': log.level === 'error',
'bg-yellow-100/80 text-yellow-800 dark:bg-yellow-500/10 dark:text-yellow-400': log.level === 'warning',
'bg-blue-100/80 text-blue-800 dark:bg-blue-500/10 dark:text-blue-400': log.level === 'info',
'bg-gray-100/80 text-gray-800 dark:bg-gray-500/10 dark:text-gray-400': log.level === 'debug',
})}
>
{log.level}
</span>
<p className="flex-1 text-sm text-bolt-elements-textPrimary">{log.message}</p>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={handleCopy} className="p-1 transition-colors rounded focus:outline-none" title="Copy log">
<div
className={classNames(
'text-base transition-colors',
isCopied
? 'i-ph:check text-green-500'
: 'i-ph:copy text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary',
)}
/>
</button>
{log.details && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary transition-colors rounded focus:outline-none"
title="Toggle details"
>
<div
className={classNames('text-base transition-transform', {
'i-ph:caret-down rotate-180': isExpanded,
'i-ph:caret-down': !isExpanded,
})}
/>
</button>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-1 text-xs">
<div className="flex items-center gap-1">
<div className="i-ph:clock text-bolt-elements-textTertiary" />
<span className="text-bolt-elements-textSecondary">{formattedTime.primary}</span>
<span className="text-bolt-elements-textTertiary">·</span>
<span className="text-bolt-elements-textTertiary">{formattedTime.secondary}</span>
</div>
<span className="px-2 py-0.5 rounded-full bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3">
{log.category}
</span>
</div>
</div>
{isExpanded && log.details && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 px-3"
>
<pre className="p-2 text-sm rounded-md overflow-auto bg-bolt-elements-background-depth-2/50 dark:bg-bolt-elements-background-depth-3/50 text-bolt-elements-textSecondary">
{JSON.stringify(log.details, null, 2)}
</pre>
</motion.div>
)}
</div>
);
};
/**
* TODO: Future Enhancements
*
* 1. Advanced Features:
* - Add export to JSON/CSV functionality
* - Implement log retention policies
* - Add custom alert rules and notifications
* - Add pattern detection and highlighting
*
* 2. Visual Improvements:
* - Add dark/light mode specific styling
* - Implement collapsible JSON viewer
* - Add timeline view with zoom capabilities
*
* 3. Performance Optimizations:
* - Implement virtualized scrolling for large logs
* - Add lazy loading for log details
* - Optimize search with indexing
*/
export function EventLogsTab() {
const logs = useStore(logStore.logs);
const [logLevel, setLogLevel] = useState<LogEntry['level'] | 'all'>('info');
const [logLevel, setLogLevel] = useState<LogEntry['level'] | 'all'>('all');
const [logCategory, setLogCategory] = useState<LogEntry['category'] | 'all'>('all');
const [autoScroll, setAutoScroll] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [, forceUpdate] = useState({});
const [expandAll, setExpandAll] = useState(false);
const [use24Hour, setUse24Hour] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const logsContainerRef = useRef<HTMLDivElement>(null);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
// Add refresh function
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
// Since logStore doesn't have refresh, we'll re-fetch logs
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate refresh
toast.success('Logs refreshed');
} catch (err) {
console.error('Failed to refresh logs:', err);
toast.error('Failed to refresh logs');
} finally {
setIsRefreshing(false);
}
}, []);
const filteredLogs = useMemo(() => {
const allLogs = Object.values(logs);
const filtered = allLogs.filter((log) => {
const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all';
const matchesLevel = logLevel === 'all' || log.level === logLevel;
const matchesCategory = logCategory === 'all' || log.category === logCategory;
const matchesSearch =
!searchQuery ||
log.message?.toLowerCase().includes(searchQuery.toLowerCase()) ||
JSON.stringify(log.details)?.toLowerCase()?.includes(searchQuery?.toLowerCase());
return matchesLevel && matchesSearch;
return matchesLevel && matchesCategory && matchesSearch;
});
return filtered.reverse();
}, [logs, logLevel, searchQuery]);
// Effect to initialize showLogs
useEffect(() => {
logStore.showLogs.set(true);
}, []);
useEffect(() => {
// System info logs
logStore.logSystem('Application initialized', {
version: process.env.NEXT_PUBLIC_APP_VERSION,
environment: process.env.NODE_ENV,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
});
// Debug logs for system state
logStore.logDebug('System configuration loaded', {
runtime: 'Next.js',
features: ['AI Chat', 'Event Logging', 'Provider Management', 'Theme Support'],
locale: navigator.language,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
// Performance metrics
logStore.logSystem('Performance metrics', {
deviceMemory: (navigator as any).deviceMemory || 'unknown',
hardwareConcurrency: navigator.hardwareConcurrency,
connectionType: (navigator as any).connection?.effectiveType || 'unknown',
});
// Provider status
logStore.logProvider('Provider status check', {
availableProviders: ['OpenAI', 'Anthropic', 'Mistral', 'Ollama'],
defaultProvider: 'OpenAI',
status: 'operational',
});
// Theme and accessibility
logStore.logSystem('User preferences loaded', {
theme: document.documentElement.dataset.theme || 'system',
prefersReducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
prefersDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
});
// Warning logs for potential issues
logStore.logWarning('Resource usage threshold approaching', {
memoryUsage: '75%',
cpuLoad: '60%',
timestamp: new Date().toISOString(),
});
// Security checks
logStore.logSystem('Security status', {
httpsEnabled: window.location.protocol === 'https:',
cookiesEnabled: navigator.cookieEnabled,
storageQuota: 'checking...',
});
// Error logs with detailed context
logStore.logError('API connection failed', new Error('Connection timeout'), {
endpoint: '/api/chat',
retryCount: 3,
lastAttempt: new Date().toISOString(),
statusCode: 408,
});
// Debug logs for development
if (process.env.NODE_ENV === 'development') {
logStore.logDebug('Development mode active', {
debugFlags: true,
mockServices: false,
apiEndpoint: 'local',
});
}
}, []);
// Scroll handling
useEffect(() => {
const container = logsContainerRef.current;
if (!container) {
return undefined;
}
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const isBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10;
setIsScrolledToBottom(isBottom);
};
container.addEventListener('scroll', handleScroll);
const cleanup = () => {
container.removeEventListener('scroll', handleScroll);
};
return cleanup;
}, []);
// Auto-scroll effect
useEffect(() => {
const container = logsContainerRef.current;
if (container && (autoScroll || isScrolledToBottom)) {
container.scrollTop = 0;
}
}, [filteredLogs, autoScroll, isScrolledToBottom]);
}, [logs, logLevel, logCategory, searchQuery]);
const handleClearLogs = useCallback(() => {
if (confirm('Are you sure you want to clear all logs?')) {
logStore.clearLogs();
toast.success('Logs cleared successfully');
forceUpdate({}); // Force a re-render after clearing logs
}
}, []);
@ -177,223 +330,188 @@ export default function EventLogsTab() {
}
}, []);
const getLevelIcon = (level: LogEntry['level']): string => {
switch (level) {
case 'info':
return 'i-ph:info';
case 'warning':
return 'i-ph:warning';
case 'error':
return 'i-ph:x-circle';
case 'debug':
return 'i-ph:bug';
default:
return 'i-ph:circle';
const handleScroll = () => {
const container = logsContainerRef.current;
if (!container) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = container;
const isBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10;
setIsScrolledToBottom(isBottom);
};
const getLevelColor = (level: LogEntry['level']) => {
switch (level) {
case 'info':
return 'text-[#1389FD] dark:text-[#1389FD]';
case 'warning':
return 'text-[#FFDB6C] dark:text-[#FFDB6C]';
case 'error':
return 'text-[#EE4744] dark:text-[#EE4744]';
case 'debug':
return 'text-[#77828D] dark:text-[#77828D]';
default:
return 'text-bolt-elements-textPrimary';
useEffect(() => {
const container = logsContainerRef.current;
if (container && (autoScroll || isScrolledToBottom)) {
container.scrollTop = container.scrollHeight;
}
};
}, [filteredLogs, autoScroll, isScrolledToBottom]);
return (
<div className="space-y-4">
<div className="flex flex-col space-y-4">
{/* Title and Toggles Row */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex items-center gap-2">
<div className="i-ph:list-bullets text-xl text-purple-500" />
<div className="flex flex-col h-full gap-4 p-6">
{/* Header Section */}
<div className="flex flex-col gap-4 pb-4">
{/* Title and Refresh */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="i-ph:list text-xl text-purple-500" />
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Event Logs</h3>
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Event Logs</h2>
<p className="text-sm text-bolt-elements-textSecondary">Track system events and debug information</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<div className="i-ph:eye text-bolt-elements-textSecondary" />
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Show Actions</span>
<Switch checked={showLogs} onCheckedChange={(checked) => logStore.showLogs.set(checked)} />
<motion.button
onClick={handleRefresh}
disabled={isRefreshing}
className={classNames(
'p-2.5 rounded-lg',
'bg-purple-50/50 dark:bg-purple-900/10',
'text-purple-500 hover:text-purple-600',
'hover:bg-purple-100/50 dark:hover:bg-purple-900/20',
'focus:outline-none focus:ring-2 focus:ring-purple-500/20',
'transition-all duration-200 ease-in-out',
{ 'opacity-50 cursor-not-allowed': isRefreshing },
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
title="Refresh logs"
>
<div className={classNames('text-lg transition-all duration-300', { 'animate-spin': isRefreshing })}>
<div className="i-ph:arrows-clockwise" />
</div>
<div className="flex items-center gap-2">
<div className="i-ph:arrow-clockwise text-bolt-elements-textSecondary" />
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Auto-scroll</span>
<Switch checked={autoScroll} onCheckedChange={setAutoScroll} />
</div>
</div>
</motion.button>
</div>
{/* Controls Row */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[150px] max-w-[200px]">
<div className="relative group">
<select
value={logLevel}
onChange={(e) => setLogLevel(e.target.value as LogEntry['level'])}
className={classNames(
'w-full pl-9 pr-3 py-2 rounded-lg',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-sm text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'group-hover:border-purple-500/30',
'transition-all duration-200',
)}
>
<option value="all">All Levels</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="debug">Debug</option>
</select>
<div className="i-ph:funnel absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textSecondary group-hover:text-purple-500 transition-colors" />
</div>
</div>
<div className="flex-1 min-w-[200px]">
<div className="relative group">
<input
type="text"
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={classNames(
'w-full pl-9 pr-3 py-2 rounded-lg',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-sm text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'group-hover:border-purple-500/30',
'transition-all duration-200',
)}
{/* Controls Section */}
<div className="flex items-center justify-end gap-2 px-1">
<div className="flex items-center gap-6 p-1.5 rounded-lg bg-white/50 dark:bg-gray-800/30 border border-gray-200/50 dark:border-gray-700/50">
<motion.div
className="flex items-center gap-3"
whileHover={{ scale: 1.02 }}
transition={{ type: 'spring', stiffness: 400, damping: 20 }}
>
<span className="text-sm font-medium text-bolt-elements-textSecondary">Auto-scroll</span>
<Switch
checked={autoScroll}
onCheckedChange={setAutoScroll}
className="data-[state=checked]:bg-purple-500"
/>
<div className="i-ph:magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textSecondary group-hover:text-purple-500 transition-colors" />
</div>
</motion.div>
<div className="h-4 w-px bg-bolt-elements-borderColor" />
<motion.div
className="flex items-center gap-3"
whileHover={{ scale: 1.02 }}
transition={{ type: 'spring', stiffness: 400, damping: 20 }}
>
<span className="text-sm font-medium text-bolt-elements-textSecondary">24h Time</span>
<Switch
checked={use24Hour}
onCheckedChange={setUse24Hour}
className="data-[state=checked]:bg-purple-500"
/>
</motion.div>
<div className="h-4 w-px bg-bolt-elements-borderColor" />
<motion.div
className="flex items-center gap-3"
whileHover={{ scale: 1.02 }}
transition={{ type: 'spring', stiffness: 400, damping: 20 }}
>
<span className="text-sm font-medium text-bolt-elements-textSecondary">Expand All</span>
<Switch
checked={expandAll}
onCheckedChange={setExpandAll}
className="data-[state=checked]:bg-purple-500"
/>
</motion.div>
</div>
{showLogs && (
<div className="flex items-center gap-2 flex-nowrap">
<motion.button
onClick={handleExportLogs}
className={classNames(settingsStyles.button.base, settingsStyles.button.primary, 'group')}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:download-simple group-hover:scale-110 transition-transform" />
Export Logs
</motion.button>
<motion.button
onClick={handleClearLogs}
className={classNames(settingsStyles.button.base, settingsStyles.button.danger, 'group')}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:trash group-hover:scale-110 transition-transform" />
Clear Logs
</motion.button>
</div>
)}
</div>
</div>
<motion.div
{/* Header with Search */}
<div className="flex flex-col gap-4">
<div className="relative w-72">
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary">
<div className="i-ph:magnifying-glass text-base" />
</div>
<input
type="text"
placeholder="Search logs..."
className={classNames(
'w-full pl-8 pr-3 py-1.5 rounded-md text-sm',
'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-purple-500/30 focus:border-purple-500/30',
'transition-all duration-200',
)}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Filters Row */}
<div className="flex items-center -ml-1">
<SegmentedGroup
value={logLevel}
onChange={(value) => setLogLevel(value as LogEntry['level'] | 'all')}
options={logLevelOptions}
/>
<div className="mx-2 w-px h-4 bg-bolt-elements-borderColor" />
<SegmentedGroup
value={logCategory}
onChange={(value) => setLogCategory(value as LogEntry['category'] | 'all')}
options={logCategoryOptions}
/>
</div>
</div>
{/* Logs Display */}
<div
ref={logsContainerRef}
className={classNames(
settingsStyles.card,
'h-[calc(100vh-250px)] min-h-[400px] overflow-y-auto logs-container',
'scrollbar-thin scrollbar-thumb-bolt-elements-borderColor scrollbar-track-transparent hover:scrollbar-thumb-purple-500/30',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex-1 overflow-auto rounded-lg bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2"
onScroll={handleScroll}
>
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', duration: 0.5 }}
className="i-ph:clipboard-text text-6xl text-bolt-elements-textSecondary mb-4"
/>
<motion.p
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-bolt-elements-textSecondary"
>
No logs found
</motion.p>
</div>
) : (
<div className="divide-y divide-bolt-elements-borderColor">
{filteredLogs.map((log, index) => (
<motion.div
key={index}
className={classNames(
'p-4 font-mono hover:bg-bolt-elements-background-depth-3 transition-colors duration-200',
{ 'border-t border-bolt-elements-borderColor': index === 0 },
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<div className="flex items-start gap-3">
<div
className={classNames(
getLevelIcon(log.level),
getLevelColor(log.level),
'mt-1 flex-shrink-0 text-lg',
)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className={classNames(
'font-bold whitespace-nowrap px-2 py-0.5 rounded-full text-xs',
{
'bg-blue-500/10': log.level === 'info',
'bg-yellow-500/10': log.level === 'warning',
'bg-red-500/10': log.level === 'error',
'bg-bolt-elements-textSecondary/10': log.level === 'debug',
},
getLevelColor(log.level),
)}
>
{log.level.toUpperCase()}
</span>
<span className="text-bolt-elements-textSecondary whitespace-nowrap text-xs">
{new Date(log.timestamp).toLocaleString()}
</span>
<span className="text-bolt-elements-textPrimary break-all">{log.message}</span>
</div>
{log.details && (
<motion.pre
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{ duration: 0.2 }}
className={classNames(
'mt-2 text-xs',
'overflow-x-auto whitespace-pre-wrap break-all',
'bg-[#1A1A1A] dark:bg-[#0A0A0A] rounded-md p-3',
'border border-[#333333] dark:border-[#1A1A1A]',
'text-[#666666] dark:text-[#999999]',
)}
>
{JSON.stringify(log.details, null, 2)}
</motion.pre>
)}
</div>
</div>
</motion.div>
))}
</div>
)}
</motion.div>
<div className="divide-y divide-bolt-elements-borderColor">
{filteredLogs.map((log) => (
<LogEntryItem key={log.id} log={log} isExpanded={expandAll} use24Hour={use24Hour} />
))}
</div>
</div>
{/* Status Bar */}
<div className="flex items-center justify-between py-2 px-4 text-sm text-bolt-elements-textSecondary">
<div className="flex items-center gap-6">
<span>{filteredLogs.length} logs displayed</span>
<span>{isScrolledToBottom ? 'Watching for new logs...' : 'Scroll to bottom to watch new logs'}</span>
</div>
<div className="flex items-center gap-2">
<motion.button
onClick={handleExportLogs}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-purple-500/10 text-purple-600 dark:text-purple-400 hover:bg-purple-500/20 rounded-md transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:download-simple" />
Export
</motion.button>
<motion.button
onClick={handleClearLogs}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 rounded-md transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:trash" />
Clear
</motion.button>
</div>
</div>
</div>
);
}

View File

@ -1,11 +1,12 @@
import React, { useState } from 'react';
import React from 'react';
import { motion } from 'framer-motion';
import { Switch } from '~/components/ui/Switch';
import { PromptLibrary } from '~/lib/common/prompt-library';
import { useSettings } from '~/lib/hooks/useSettings';
import { motion, AnimatePresence } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import { settingsStyles } from '~/components/settings/settings.styles';
import { toast } from 'react-toastify';
import { PromptLibrary } from '~/lib/common/prompt-library';
import { useStore } from '@nanostores/react';
import { isEventLogsEnabled } from '~/lib/stores/settings';
interface FeatureToggle {
id: string;
@ -20,11 +21,9 @@ interface FeatureToggle {
export default function FeaturesTab() {
const {
debug,
enableDebugMode,
setEventLogs,
isLocalModel,
enableLocalModels,
enableEventLogs,
isLatestBranch,
enableLatestBranch,
promptId,
@ -35,25 +34,9 @@ export default function FeaturesTab() {
contextOptimizationEnabled,
} = useSettings();
const [hoveredFeature, setHoveredFeature] = useState<string | null>(null);
const [expandedFeature, setExpandedFeature] = useState<string | null>(null);
const handleToggle = (enabled: boolean) => {
enableDebugMode(enabled);
enableEventLogs(enabled);
toast.success(`Debug features ${enabled ? 'enabled' : 'disabled'}`);
};
const eventLogs = useStore(isEventLogsEnabled);
const features: FeatureToggle[] = [
{
id: 'debug',
title: 'Debug Features',
description: 'Enable debugging tools and detailed logging',
icon: 'i-ph:bug',
enabled: debug,
experimental: true,
tooltip: 'Access advanced debugging tools and view detailed system logs',
},
{
id: 'latestBranch',
title: 'Use Main Branch',
@ -88,13 +71,18 @@ export default function FeaturesTab() {
experimental: true,
tooltip: 'Try out new AI providers and models in development',
},
{
id: 'eventLogs',
title: 'Event Logging',
description: 'Enable detailed event logging and history',
icon: 'i-ph:list-bullets',
enabled: eventLogs,
tooltip: 'Record detailed logs of system events and user actions',
},
];
const handleToggleFeature = (featureId: string, enabled: boolean) => {
switch (featureId) {
case 'debug':
handleToggle(enabled);
break;
case 'latestBranch':
enableLatestBranch(enabled);
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
@ -111,13 +99,17 @@ export default function FeaturesTab() {
enableLocalModels(enabled);
toast.success(`Experimental providers ${enabled ? 'enabled' : 'disabled'}`);
break;
case 'eventLogs':
setEventLogs(enabled);
toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
break;
}
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
<motion.div
className="flex items-center gap-2"
className="flex items-center gap-3"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
@ -125,7 +117,9 @@ export default function FeaturesTab() {
<div className="i-ph:puzzle-piece text-xl text-purple-500" />
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Features</h3>
<p className="text-sm text-bolt-elements-textSecondary">Customize your Bolt experience</p>
<p className="text-sm text-bolt-elements-textSecondary">
Customize your Bolt experience with experimental features
</p>
</div>
</motion.div>
@ -139,39 +133,16 @@ export default function FeaturesTab() {
<motion.div
key={feature.id}
className={classNames(
settingsStyles.card,
'relative group cursor-pointer',
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-colors duration-200',
'relative overflow-hidden group cursor-pointer',
'rounded-lg overflow-hidden',
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
onHoverStart={() => setHoveredFeature(feature.id)}
onHoverEnd={() => setHoveredFeature(null)}
onClick={() => setExpandedFeature(expandedFeature === feature.id ? null : feature.id)}
>
<AnimatePresence>
{hoveredFeature === feature.id && feature.tooltip && (
<motion.div
className={classNames(
'absolute -top-12 left-1/2 transform -translate-x-1/2',
'px-3 py-2 rounded-lg text-xs',
'bg-bolt-elements-background-depth-4 text-bolt-elements-textPrimary',
'border border-bolt-elements-borderColor',
'whitespace-nowrap z-10',
)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
>
{feature.tooltip}
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2 rotate-45 w-2 h-2 bg-bolt-elements-background-depth-4 border-r border-b border-bolt-elements-borderColor" />
</motion.div>
)}
</AnimatePresence>
<div className="absolute top-0 right-0 p-2 flex gap-1">
{feature.beta && (
<motion.span
@ -236,10 +207,10 @@ export default function FeaturesTab() {
<motion.div
className={classNames(
settingsStyles.card,
'bg-bolt-elements-background-depth-2',
'hover:bg-bolt-elements-background-depth-3',
'transition-all duration-200',
'rounded-lg',
'group',
)}
initial={{ opacity: 0, y: 20 }}

View File

@ -0,0 +1,143 @@
import React, { useState } 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';
interface NotificationDetails {
type?: string;
message?: string;
currentVersion?: string;
latestVersion?: string;
branch?: string;
updateUrl?: string;
}
const NotificationsTab = () => {
const [filter, setFilter] = useState<'all' | 'error' | 'warning'>('all');
const logs = useStore(logStore.logs);
const handleClearNotifications = () => {
logStore.clearLogs();
};
const handleUpdateAction = (updateUrl: string) => {
window.open(updateUrl, '_blank');
};
const filteredLogs = Object.values(logs)
.filter((log) => {
if (filter === 'all') {
return log.level === 'error' || log.level === 'warning';
}
return log.level === filter;
})
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const renderNotificationDetails = (details: NotificationDetails) => {
if (details.type === 'update') {
return (
<div className="flex flex-col gap-2">
<p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p>
<div className="flex flex-col gap-1 text-xs text-gray-500 dark:text-gray-500">
<p>Current Version: {details.currentVersion}</p>
<p>Latest Version: {details.latestVersion}</p>
<p>Branch: {details.branch}</p>
</div>
<button
onClick={() => details.updateUrl && handleUpdateAction(details.updateUrl)}
className="mt-2 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:hover:bg-blue-900/30"
>
<span className="i-ph:git-branch text-lg" />
View Changes
</button>
</div>
);
}
return details.message ? <p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p> : null;
};
return (
<div className="flex h-full flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<select
value={filter}
onChange={(e) => setFilter(e.target.value as 'all' | 'error' | 'warning')}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="all">All Notifications</option>
<option value="error">Errors</option>
<option value="warning">Warnings</option>
</select>
</div>
<button
onClick={handleClearNotifications}
className="rounded-md bg-gray-50 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
>
Clear All
</button>
</div>
<div className="flex flex-col gap-4">
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-gray-200 p-8 text-center dark:border-gray-700">
<span className="i-ph:bell-slash text-4xl text-gray-400 dark:text-gray-600" />
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">No Notifications</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">You're all caught up!</p>
</div>
</div>
) : (
filteredLogs.map((log) => (
<motion.div
key={log.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={classNames(
'flex flex-col gap-2 rounded-lg border p-4',
log.level === 'error'
? 'border-red-200 bg-red-50 dark:border-red-900/50 dark:bg-red-900/20'
: 'border-yellow-200 bg-yellow-50 dark:border-yellow-900/50 dark:bg-yellow-900/20',
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<span
className={classNames(
'text-lg',
log.level === 'error'
? 'i-ph:warning-circle text-red-600 dark:text-red-400'
: 'i-ph:warning text-yellow-600 dark:text-yellow-400',
)}
/>
<div>
<h3
className={classNames(
'text-sm font-medium',
log.level === 'error'
? 'text-red-900 dark:text-red-300'
: 'text-yellow-900 dark:text-yellow-300',
)}
>
{log.message}
</h3>
{log.details && renderNotificationDetails(log.details as NotificationDetails)}
</div>
</div>
<time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
</time>
</div>
</motion.div>
))
)}
</div>
</div>
);
};
export default NotificationsTab;

View File

@ -1,10 +1,8 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef } from 'react';
import { AnimatePresence } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { Switch } from '~/components/ui/Switch';
import type { UserProfile } from '~/components/settings/settings.types';
import { themeStore, kTheme } from '~/lib/stores/theme';
import { motion } from 'framer-motion';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
@ -15,7 +13,6 @@ export default function ProfileTab() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [currentTimezone, setCurrentTimezone] = useState('');
const [profile, setProfile] = useState<UserProfile>(() => {
const saved = localStorage.getItem('bolt_user_profile');
return saved
@ -23,35 +20,11 @@ export default function ProfileTab() {
: {
name: '',
email: '',
theme: 'system',
notifications: true,
language: 'en',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
password: '',
bio: '',
};
});
useEffect(() => {
setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}, []);
// Apply theme when profile changes
useEffect(() => {
if (profile.theme === 'system') {
// Remove theme override
localStorage.removeItem(kTheme);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.querySelector('html')?.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
} else {
// Set specific theme
localStorage.setItem(kTheme, profile.theme);
document.querySelector('html')?.setAttribute('data-theme', profile.theme);
themeStore.set(profile.theme);
}
}, [profile.theme]);
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@ -105,7 +78,20 @@ export default function ProfileTab() {
setIsLoading(true);
try {
localStorage.setItem('bolt_user_profile', JSON.stringify(profile));
// Get existing profile data to preserve settings
const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
// Merge with new profile data
const updatedProfile = {
...existingProfile,
name: profile.name,
email: profile.email,
password: profile.password,
bio: profile.bio,
avatar: profile.avatar,
};
localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
toast.success('Profile settings saved successfully');
} catch (error) {
console.error('Error saving profile:', error);
@ -117,259 +103,142 @@ export default function ProfileTab() {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4">
{/* Profile Information */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
<div className="i-ph:user-circle-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Personal Information</span>
</div>
<div className="flex items-start gap-4 p-4">
{/* Avatar */}
<div className="relative group">
<div className="w-12 h-12 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] flex items-center justify-center overflow-hidden">
<AnimatePresence mode="wait">
{isLoading ? (
<div className="i-ph:spinner-gap-bold animate-spin text-purple-500" />
) : profile.avatar ? (
<img src={profile.avatar} alt="Profile" className="w-full h-full object-cover" />
) : (
<div className="i-ph:user-circle-fill text-bolt-elements-textSecondary" />
)}
</AnimatePresence>
</div>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg"
>
<div className="i-ph:camera-fill text-white" />
</button>
<input
ref={fileInputRef}
type="file"
accept={ALLOWED_FILE_TYPES.join(',')}
onChange={handleAvatarUpload}
className="hidden"
/>
{/* Profile Information */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
<div className="i-ph:user-circle-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Personal Information</span>
</div>
<div className="flex items-start gap-4 p-4">
{/* Avatar */}
<div className="relative group">
<div className="w-12 h-12 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] flex items-center justify-center overflow-hidden">
<AnimatePresence mode="wait">
{isLoading ? (
<div className="i-ph:spinner-gap-bold animate-spin text-purple-500" />
) : profile.avatar ? (
<img src={profile.avatar} alt="Profile" className="w-full h-full object-cover" />
) : (
<div className="i-ph:user-circle-fill text-bolt-elements-textSecondary" />
)}
</AnimatePresence>
</div>
{/* Profile Fields */}
<div className="flex-1 space-y-3">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2">
<div className="i-ph:user-fill w-4 h-4 text-bolt-elements-textTertiary" />
</div>
<input
type="text"
value={profile.name}
onChange={(e) => setProfile((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Enter your name"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'pl-10',
'bg-[#F5F5F5] 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',
)}
/>
</div>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2">
<div className="i-ph:envelope-fill w-4 h-4 text-bolt-elements-textTertiary" />
</div>
<input
type="email"
value={profile.email}
onChange={(e) => setProfile((prev) => ({ ...prev, email: e.target.value }))}
placeholder="Enter your email"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'pl-10',
'bg-[#F5F5F5] 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',
)}
/>
</div>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={profile.password}
onChange={(e) => setProfile((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Enter new password"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'bg-[#F5F5F5] 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',
)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={classNames(
'absolute right-3 top-1/2 -translate-y-1/2',
'flex items-center justify-center',
'w-6 h-6 rounded-md',
'text-bolt-elements-textSecondary',
'hover:text-bolt-elements-item-contentActive',
'hover:bg-bolt-elements-item-backgroundActive',
'transition-colors',
)}
>
<div className={classNames(showPassword ? 'i-ph:eye-slash-fill' : 'i-ph:eye-fill', 'w-4 h-4')} />
</button>
</div>
</div>
</div>
</motion.div>
{/* Theme & Language */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4 space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center gap-2 mb-4">
<div className="i-ph:palette-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Appearance</span>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:paint-brush-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Theme</label>
</div>
<div className="flex gap-2">
{(['light', 'dark', 'system'] as const).map((theme) => (
<button
key={theme}
onClick={() => setProfile((prev) => ({ ...prev, theme }))}
className={classNames(
'px-3 py-1.5 rounded-lg text-sm flex items-center gap-2 transition-colors',
profile.theme === theme
? 'bg-purple-500 text-white hover:bg-purple-600'
: 'bg-[#F5F5F5] dark:bg-[#1A1A1A] text-bolt-elements-textSecondary hover:bg-[#E5E5E5] dark:hover:bg-[#252525] hover:text-bolt-elements-textPrimary',
)}
>
<div
className={`w-4 h-4 ${
theme === 'light'
? 'i-ph:sun-fill'
: theme === 'dark'
? 'i-ph:moon-stars-fill'
: 'i-ph:monitor-fill'
}`}
/>
<span className="capitalize">{theme}</span>
</button>
))}
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:translate-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Language</label>
</div>
<select
value={profile.language}
onChange={(e) => setProfile((prev) => ({ ...prev, language: e.target.value }))}
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-purple-500',
)}
>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="it">Italiano</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
<option value="zh"></option>
<option value="ja"></option>
<option value="ko"></option>
</select>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:bell-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Notifications</label>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-bolt-elements-textSecondary">
{profile.notifications ? 'Notifications are enabled' : 'Notifications are disabled'}
</span>
<Switch
checked={profile.notifications}
onCheckedChange={(checked) => setProfile((prev) => ({ ...prev, notifications: checked }))}
/>
</div>
</div>
</motion.div>
{/* Timezone */}
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4">
<div className="flex items-center gap-2 mb-4">
<div className="i-ph:clock-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Time Settings</span>
</div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:globe-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Timezone</label>
</div>
<div className="flex gap-2">
<select
value={profile.timezone}
onChange={(e) => setProfile((prev) => ({ ...prev, timezone: e.target.value }))}
className={classNames(
'flex-1 px-3 py-1.5 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-purple-500',
)}
>
{Intl.supportedValuesOf('timeZone').map((tz) => (
<option key={tz} value={tz}>
{tz.replace(/_/g, ' ')}
</option>
))}
</select>
<button
onClick={() => setProfile((prev) => ({ ...prev, timezone: currentTimezone }))}
className={classNames(
'px-3 py-1.5 rounded-lg text-sm flex items-center gap-2',
'bg-[#F5F5F5] dark:bg-[#1A1A1A] text-bolt-elements-textSecondary',
'hover:text-bolt-elements-textPrimary',
)}
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg"
>
<div className="i-ph:crosshair-simple-fill" />
Auto-detect
<div className="i-ph:camera-fill text-white" />
</button>
<input
ref={fileInputRef}
type="file"
accept={ALLOWED_FILE_TYPES.join(',')}
onChange={handleAvatarUpload}
className="hidden"
/>
</div>
{/* Profile Fields */}
<div className="flex-1 space-y-3">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2">
<div className="i-ph:user-fill w-4 h-4 text-bolt-elements-textTertiary" />
</div>
<input
type="text"
value={profile.name}
onChange={(e) => setProfile((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Enter your name"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'pl-10',
'bg-[#F5F5F5] 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',
)}
/>
</div>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2">
<div className="i-ph:envelope-fill w-4 h-4 text-bolt-elements-textTertiary" />
</div>
<input
type="email"
value={profile.email}
onChange={(e) => setProfile((prev) => ({ ...prev, email: e.target.value }))}
placeholder="Enter your email"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'pl-10',
'bg-[#F5F5F5] 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',
)}
/>
</div>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={profile.password}
onChange={(e) => setProfile((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Enter new password"
className={classNames(
'w-full px-3 py-1.5 rounded-lg text-sm',
'bg-[#F5F5F5] 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',
)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={classNames(
'absolute right-3 top-1/2 -translate-y-1/2',
'flex items-center justify-center',
'w-6 h-6 rounded-md',
'text-bolt-elements-textSecondary',
'hover:text-bolt-elements-item-contentActive',
'hover:bg-bolt-elements-item-backgroundActive',
'transition-colors',
)}
>
<div className={classNames(showPassword ? 'i-ph:eye-slash-fill' : 'i-ph:eye-fill', 'w-4 h-4')} />
</button>
</div>
<div className="relative">
<textarea
value={profile.bio}
onChange={(e) => setProfile((prev) => ({ ...prev, bio: e.target.value }))}
placeholder="Tell us about yourself"
rows={3}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] 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',
'resize-none',
)}
/>
</div>
</div>
</div>
</div>
</motion.div>
{/* Save Button */}
<motion.div
className="flex justify-end mt-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
transition={{ delay: 0.2 }}
>
<button
onClick={handleSave}

View File

@ -85,8 +85,8 @@ interface CategoryToggleState {
local: boolean;
}
export default function ProvidersTab() {
const { providers, updateProviderSettings, isLocalModel } = useSettings();
export const ProvidersTab = () => {
const settings = useSettings();
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
const [categoryEnabled, setCategoryEnabled] = useState<CategoryToggleState>({
@ -128,12 +128,12 @@ export default function ProvidersTab() {
// Get providers for this category
const categoryProviders = groupedProviders[category].providers;
categoryProviders.forEach((provider) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
});
toast.success(enabled ? `All ${category} providers enabled` : `All ${category} providers disabled`);
},
[groupedProviders, updateProviderSettings],
[groupedProviders, settings.updateProviderSettings],
);
// Add effect to update category toggle states based on provider states
@ -147,26 +147,32 @@ export default function ProvidersTab() {
// Effect to filter and sort providers
useEffect(() => {
let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({
...value,
name: key,
}));
const newFilteredProviders = Object.entries(settings.providers || {}).map(([key, value]) => {
const provider = value as IProviderConfig;
return {
name: key,
settings: provider.settings,
staticModels: provider.staticModels || [],
getDynamicModels: provider.getDynamicModels,
getApiKeyLink: provider.getApiKeyLink,
labelForGetApiKey: provider.labelForGetApiKey,
icon: provider.icon,
} as IProviderConfig;
});
if (!isLocalModel) {
newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name));
}
const filtered = !settings.isLocalModel
? newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name))
: newFilteredProviders;
newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
// Split providers into regular and URL-configurable
const regular = newFilteredProviders.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name));
const urlConfigurable = newFilteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
const regular = sorted.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name));
const urlConfigurable = sorted.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
setFilteredProviders([...regular, ...urlConfigurable]);
}, [providers, isLocalModel]);
}, [settings.providers, settings.isLocalModel]);
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
if (enabled) {
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
@ -184,7 +190,7 @@ export default function ProvidersTab() {
newBaseUrl = undefined;
}
updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
settings.updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
logStore.logProvider(`Base URL updated for ${provider.name}`, {
provider: provider.name,
baseUrl: newBaseUrl,
@ -404,4 +410,4 @@ export default function ProvidersTab() {
))}
</div>
);
}
};

View File

@ -7,14 +7,14 @@ export function cn(...inputs: ClassValue[]) {
export const settingsStyles = {
// Card styles
card: 'bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]',
card: 'bg-bolt-elements-background dark:bg-bolt-elements-backgroundDark rounded-lg p-6 border border-bolt-elements-border dark:border-bolt-elements-borderDark',
// Button styles
button: {
base: 'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed',
primary: 'bg-purple-500 text-white hover:bg-purple-600',
secondary:
'bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white',
'bg-bolt-elements-hover dark:bg-bolt-elements-hoverDark text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondaryDark hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimaryDark',
danger: 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20',
warning: 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
success: 'bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-500/10 dark:hover:bg-green-500/20',
@ -22,15 +22,21 @@ export const settingsStyles = {
// Form styles
form: {
label: 'block text-sm text-bolt-elements-textSecondary mb-2',
label: 'block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondaryDark mb-2',
input:
'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',
'w-full px-3 py-2 rounded-lg text-sm bg-bolt-elements-hover dark:bg-bolt-elements-hoverDark border border-bolt-elements-border dark:border-bolt-elements-borderDark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimaryDark placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-purple-500',
},
// Search container
search: {
input:
'w-full h-10 pl-10 pr-4 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 transition-all',
'w-full h-10 pl-10 pr-4 rounded-lg text-sm bg-bolt-elements-hover dark:bg-bolt-elements-hoverDark border border-bolt-elements-border dark:border-bolt-elements-borderDark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimaryDark placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-purple-500 transition-all',
},
// Scroll container styles
scroll: {
container: 'overflow-y-auto overscroll-y-contain',
content: 'min-h-full',
},
'loading-spinner': 'i-ph:spinner-gap-bold animate-spin w-4 h-4',

View File

@ -1,15 +1,20 @@
import type { ReactNode } from 'react';
export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences';
export type TabType =
| 'profile'
| 'settings'
| 'notifications'
| 'features'
| 'data'
| 'providers'
| 'features'
| 'connection'
| 'debug'
| 'event-logs'
| 'connection'
| 'preferences';
| 'update';
export type WindowType = 'user' | 'developer';
export interface UserProfile {
name: string;
@ -34,6 +39,60 @@ export interface SettingItem {
keywords?: string[];
}
export interface TabVisibilityConfig {
id: TabType;
visible: boolean;
window: 'user' | 'developer';
order: number;
locked?: boolean;
}
export interface TabWindowConfig {
userTabs: TabVisibilityConfig[];
developerTabs: TabVisibilityConfig[];
}
export const TAB_LABELS: Record<TabType, string> = {
profile: 'Profile',
settings: 'Settings',
notifications: 'Notifications',
features: 'Features',
data: 'Data',
providers: 'Providers',
connection: 'Connection',
debug: 'Debug',
'event-logs': 'Event Logs',
update: 'Update',
};
export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [
// User Window Tabs (Visible by default)
{ id: 'features', visible: true, window: 'user', order: 0 },
{ id: 'data', visible: true, window: 'user', order: 1 },
{ id: 'providers', visible: true, window: 'user', order: 2 },
{ id: 'connection', visible: true, window: 'user', order: 3 },
{ id: 'debug', visible: true, window: 'user', order: 4 },
// User Window Tabs (Hidden by default)
{ id: 'profile', visible: false, window: 'user', order: 5 },
{ id: 'settings', visible: false, window: 'user', order: 6 },
{ id: 'notifications', visible: false, window: 'user', order: 7 },
{ id: 'event-logs', visible: false, window: 'user', order: 8 },
{ id: 'update', visible: false, window: 'user', order: 9 },
// Developer Window Tabs (All visible by default)
{ id: 'profile', visible: true, window: 'developer', order: 0 },
{ id: 'settings', visible: true, window: 'developer', order: 1 },
{ id: 'notifications', visible: true, window: 'developer', order: 2 },
{ id: 'features', visible: true, window: 'developer', order: 3 },
{ id: 'data', visible: true, window: 'developer', order: 4 },
{ id: 'providers', visible: true, window: 'developer', order: 5 },
{ id: 'connection', visible: true, window: 'developer', order: 6 },
{ id: 'debug', visible: true, window: 'developer', order: 7 },
{ id: 'event-logs', visible: true, window: 'developer', order: 8 },
{ id: 'update', visible: true, window: 'developer', order: 9 },
];
export const categoryLabels: Record<SettingCategory, string> = {
profile: 'Profile & Account',
file_sharing: 'File Sharing',

View File

@ -0,0 +1,215 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { Switch } from '~/components/ui/Switch';
import { themeStore, kTheme } from '~/lib/stores/theme';
import type { UserProfile } from '~/components/settings/settings.types';
export default function SettingsTab() {
const [currentTimezone, setCurrentTimezone] = useState('');
const [settings, setSettings] = useState<UserProfile>(() => {
const saved = localStorage.getItem('bolt_user_profile');
return saved
? JSON.parse(saved)
: {
theme: 'system',
notifications: true,
language: 'en',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
});
useEffect(() => {
setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}, []);
// Apply theme when settings changes
useEffect(() => {
if (settings.theme === 'system') {
// Remove theme override
localStorage.removeItem(kTheme);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.querySelector('html')?.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
} else {
// Set specific theme
localStorage.setItem(kTheme, settings.theme);
document.querySelector('html')?.setAttribute('data-theme', settings.theme);
themeStore.set(settings.theme);
}
}, [settings.theme]);
// Save settings automatically when they change
useEffect(() => {
try {
// Get existing profile data
const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
// Merge with new settings
const updatedProfile = {
...existingProfile,
theme: settings.theme,
notifications: settings.notifications,
language: settings.language,
timezone: settings.timezone,
};
localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
toast.success('Settings updated');
} catch (error) {
console.error('Error saving settings:', error);
toast.error('Failed to update settings');
}
}, [settings]);
return (
<div className="space-y-4">
{/* Theme & Language */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4 space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2 mb-4">
<div className="i-ph:palette-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Appearance</span>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:paint-brush-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Theme</label>
</div>
<div className="flex gap-2">
{(['light', 'dark', 'system'] as const).map((theme) => (
<button
key={theme}
onClick={() => setSettings((prev) => ({ ...prev, theme }))}
className={classNames(
'px-3 py-1.5 rounded-lg text-sm flex items-center gap-2 transition-colors',
settings.theme === theme
? 'bg-purple-500 text-white hover:bg-purple-600'
: 'bg-[#F5F5F5] dark:bg-[#1A1A1A] text-bolt-elements-textSecondary hover:bg-[#E5E5E5] dark:hover:bg-[#252525] hover:text-bolt-elements-textPrimary',
)}
>
<div
className={`w-4 h-4 ${
theme === 'light'
? 'i-ph:sun-fill'
: theme === 'dark'
? 'i-ph:moon-stars-fill'
: 'i-ph:monitor-fill'
}`}
/>
<span className="capitalize">{theme}</span>
</button>
))}
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:translate-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Language</label>
</div>
<select
value={settings.language}
onChange={(e) => setSettings((prev) => ({ ...prev, language: e.target.value }))}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="it">Italiano</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
<option value="zh"></option>
<option value="ja"></option>
<option value="ko"></option>
</select>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:bell-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Notifications</label>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-bolt-elements-textSecondary">
{settings.notifications ? 'Notifications are enabled' : 'Notifications are disabled'}
</span>
<Switch
checked={settings.notifications}
onCheckedChange={(checked) => {
// Update local state
setSettings((prev) => ({ ...prev, notifications: checked }));
// Update localStorage immediately
const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
const updatedProfile = {
...existingProfile,
notifications: checked,
};
localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
// Dispatch storage event for other components
window.dispatchEvent(
new StorageEvent('storage', {
key: 'bolt_user_profile',
newValue: JSON.stringify(updatedProfile),
}),
);
toast.success(`Notifications ${checked ? 'enabled' : 'disabled'}`);
}}
/>
</div>
</div>
</motion.div>
{/* Timezone */}
<motion.div
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center gap-2 mb-4">
<div className="i-ph:clock-fill w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">Time Settings</span>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:globe-fill w-4 h-4 text-bolt-elements-textSecondary" />
<label className="block text-sm text-bolt-elements-textSecondary">Timezone</label>
</div>
<select
value={settings.timezone}
onChange={(e) => setSettings((prev) => ({ ...prev, timezone: e.target.value }))}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
)}
>
<option value={currentTimezone}>{currentTimezone}</option>
</select>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,158 @@
import { useDrag, useDrop } from 'react-dnd';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import type { TabVisibilityConfig } from '~/components/settings/settings.types';
import { TAB_LABELS } from '~/components/settings/settings.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 }, drag] = useDrag({
type: 'tab',
item: { type: 'tab', index, id: tab.id },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [, drop] = 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;
},
});
return (
<motion.div
ref={(node) => drag(drop(node))}
initial={false}
animate={{
scale: isDragging ? 1.02 : 1,
boxShadow: isDragging ? '0 8px 16px rgba(0,0,0,0.1)' : 'none',
}}
className={classNames(
'flex items-center justify-between p-4 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
isDragging ? 'z-50' : '',
)}
>
<div className="flex items-center gap-4">
<div className="cursor-grab">
<div className="i-ph:dots-six-vertical w-4 h-4 text-bolt-elements-textSecondary" />
</div>
<div>
<div className="font-medium text-bolt-elements-textPrimary">{TAB_LABELS[tab.id]}</div>
{showControls && (
<div className="text-xs text-bolt-elements-textSecondary">
Order: {tab.order}, Window: {tab.window}
</div>
)}
</div>
</div>
{showControls && !tab.locked && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={tab.visible}
onCheckedChange={(checked: boolean) => onVisibilityChange?.(tab, checked)}
className="data-[state=checked]:bg-purple-500"
aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`}
/>
<label className="text-sm text-bolt-elements-textSecondary">Visible</label>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-bolt-elements-textSecondary">User</label>
<Switch
checked={tab.window === 'developer'}
onCheckedChange={(checked: boolean) => onWindowChange?.(tab, checked ? 'developer' : 'user')}
className="data-[state=checked]:bg-purple-500"
aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`}
/>
<label className="text-sm text-bolt-elements-textSecondary">Dev</label>
</div>
</div>
)}
</motion.div>
);
};
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 (
<div className="space-y-2">
{tabs.map((tab, index) => (
<DraggableTabItem
key={tab.id}
tab={tab}
index={index}
moveTab={moveTab}
showControls={showControls}
onWindowChange={onWindowChange}
onVisibilityChange={onVisibilityChange}
/>
))}
</div>
);
};

View File

@ -0,0 +1,230 @@
import { motion } from 'framer-motion';
import * as Tooltip from '@radix-ui/react-tooltip';
import { classNames } from '~/utils/classNames';
import type { TabVisibilityConfig } from '~/components/settings/settings.types';
import { TAB_LABELS } from '~/components/settings/settings.types';
const TAB_ICONS = {
profile: 'i-ph:user',
settings: 'i-ph:gear',
notifications: 'i-ph:bell',
features: 'i-ph:star',
data: 'i-ph:database',
providers: 'i-ph:plug',
connection: 'i-ph:wifi-high',
debug: 'i-ph:bug',
'event-logs': 'i-ph:list-bullets',
update: 'i-ph:arrow-clockwise',
};
interface TabTileProps {
tab: TabVisibilityConfig;
onClick: () => void;
isActive?: boolean;
hasUpdate?: boolean;
statusMessage?: string;
description?: string;
isLoading?: boolean;
}
export const TabTile = ({
tab,
onClick,
isActive = false,
hasUpdate = false,
statusMessage,
description,
isLoading = false,
}: TabTileProps) => {
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<motion.button
onClick={onClick}
disabled={isLoading}
className={classNames(
'relative flex flex-col items-center justify-center gap-3 p-6 rounded-xl',
'w-full h-full min-h-[160px]',
// Background and border styles
'bg-white dark:bg-[#141414]',
'border border-[#E5E5E5]/50 dark:border-[#333333]/50',
// Shadow and glass effect
'shadow-sm backdrop-blur-sm',
'dark:shadow-[0_0_15px_rgba(0,0,0,0.1)]',
'dark:bg-opacity-50',
// Hover effects
'hover:border-purple-500/30 dark:hover:border-purple-500/30',
'hover:bg-gradient-to-br hover:from-purple-50/50 hover:to-white dark:hover:from-purple-500/5 dark:hover:to-[#141414]',
'hover:shadow-md hover:shadow-purple-500/5',
'dark:hover:shadow-purple-500/10',
// Focus states for keyboard navigation
'focus:outline-none',
'focus:ring-2 focus:ring-purple-500/50 focus:ring-offset-2',
'dark:focus:ring-offset-[#141414]',
'focus:border-purple-500/30',
// Active state
isActive
? [
'border-purple-500/50 dark:border-purple-500/50',
'bg-gradient-to-br from-purple-50 to-white dark:from-purple-500/10 dark:to-[#141414]',
'shadow-md shadow-purple-500/10',
]
: '',
// Loading state
isLoading ? 'cursor-wait opacity-70' : '',
// Transitions
'transition-all duration-300 ease-out',
'group',
)}
whileHover={
!isLoading
? {
scale: 1.02,
transition: { duration: 0.2, ease: 'easeOut' },
}
: {}
}
whileTap={
!isLoading
? {
scale: 0.98,
transition: { duration: 0.1, ease: 'easeIn' },
}
: {}
}
>
{/* Loading Overlay */}
{isLoading && (
<motion.div
className={classNames(
'absolute inset-0 rounded-xl z-10',
'bg-white/50 dark:bg-black/50',
'backdrop-blur-sm',
'flex items-center justify-center',
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
<motion.div
className={classNames('w-8 h-8 rounded-full', 'border-2 border-purple-500/30', 'border-t-purple-500')}
animate={{ rotate: 360 }}
transition={{
duration: 1,
repeat: Infinity,
ease: 'linear',
}}
/>
</motion.div>
)}
{/* Status Indicator */}
{hasUpdate && (
<motion.div
className={classNames(
'absolute top-3 right-3',
'w-2.5 h-2.5 rounded-full',
'bg-green-500',
'shadow-lg shadow-green-500/20',
'ring-4 ring-green-500/20',
)}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', bounce: 0.5 }}
/>
)}
{/* Background glow effect */}
<div
className={classNames(
'absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100',
'bg-gradient-to-br from-purple-500/5 to-transparent dark:from-purple-500/10',
'transition-opacity duration-300',
isActive ? 'opacity-100' : '',
)}
/>
{/* Icon */}
<div
className={classNames(
TAB_ICONS[tab.id],
'w-12 h-12',
'relative',
'text-gray-600 dark:text-gray-300',
'group-hover:text-purple-500 dark:group-hover:text-purple-400',
'transition-all duration-300',
isActive ? 'text-purple-500 dark:text-purple-400 scale-110' : '',
)}
/>
{/* Label and Description */}
<div className="relative flex flex-col items-center text-center">
<div
className={classNames(
'text-base font-medium',
'text-gray-700 dark:text-gray-200',
'group-hover:text-purple-500 dark:group-hover:text-purple-400',
'transition-colors duration-300',
isActive ? 'text-purple-500 dark:text-purple-400' : '',
)}
>
{TAB_LABELS[tab.id]}
</div>
{description && (
<div
className={classNames(
'text-xs mt-1',
'text-gray-500 dark:text-gray-400',
'group-hover:text-purple-400/70 dark:group-hover:text-purple-300/70',
'transition-colors duration-300',
'max-w-[180px]',
isActive ? 'text-purple-400/70 dark:text-purple-300/70' : '',
)}
>
{description}
</div>
)}
</div>
{/* Bottom indicator line */}
<div
className={classNames(
'absolute bottom-0 left-1/2 -translate-x-1/2',
'w-12 h-0.5 rounded-full',
'bg-purple-500/0 group-hover:bg-purple-500/50',
'transition-all duration-300 ease-out',
'transform scale-x-0 group-hover:scale-x-100',
isActive ? 'bg-purple-500 scale-x-100' : '',
)}
/>
</motion.button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className={classNames(
'px-3 py-1.5 rounded-lg',
'bg-[#18181B] text-white',
'text-sm font-medium',
'shadow-xl',
'select-none',
'z-[100]',
)}
side="top"
sideOffset={5}
>
{statusMessage || TAB_LABELS[tab.id]}
<Tooltip.Arrow className="fill-[#18181B]" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
};

View File

@ -0,0 +1,217 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { useSettings } from '~/lib/hooks/useSettings';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
interface GitHubCommitResponse {
sha: string;
}
interface UpdateInfo {
currentVersion: string;
latestVersion: string;
branch: string;
hasUpdate: boolean;
}
const GITHUB_URLS = {
commitJson: async (branch: string): Promise<UpdateInfo> => {
try {
const response = await fetch(`https://api.github.com/repos/stackblitz-labs/bolt.diy/commits/${branch}`);
const data = (await response.json()) as GitHubCommitResponse;
const currentCommitHash = __COMMIT_HASH;
const remoteCommitHash = data.sha.slice(0, 7);
return {
currentVersion: currentCommitHash,
latestVersion: remoteCommitHash,
branch,
hasUpdate: remoteCommitHash !== currentCommitHash,
};
} catch (error) {
console.error('Failed to fetch commit info:', error);
throw new Error('Failed to fetch commit info');
}
},
};
const UpdateTab = () => {
const { isLatestBranch } = useSettings();
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const checkForUpdates = async () => {
setIsChecking(true);
setError(null);
try {
const branchToCheck = isLatestBranch ? 'main' : 'stable';
const info = await GITHUB_URLS.commitJson(branchToCheck);
setUpdateInfo(info);
if (info.hasUpdate) {
// Add update notification only if it doesn't already exist
const existingLogs = Object.values(logStore.logs.get());
const hasUpdateNotification = existingLogs.some(
(log) =>
log.level === 'warning' &&
log.details?.type === 'update' &&
log.details.latestVersion === info.latestVersion,
);
if (!hasUpdateNotification) {
logStore.logWarning('Update Available', {
currentVersion: info.currentVersion,
latestVersion: info.latestVersion,
branch: branchToCheck,
type: 'update',
message: `A new version is available on the ${branchToCheck} branch`,
updateUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${info.currentVersion}...${info.latestVersion}`,
});
}
}
} catch (err) {
setError('Failed to check for updates. Please try again later.');
console.error('Update check failed:', err);
} finally {
setIsChecking(false);
}
};
useEffect(() => {
checkForUpdates();
}, [isLatestBranch]);
const handleViewChanges = () => {
if (updateInfo) {
window.open(
`https://github.com/stackblitz-labs/bolt.diy/compare/${updateInfo.currentVersion}...${updateInfo.latestVersion}`,
'_blank',
);
}
};
return (
<div className="flex flex-col gap-6">
<motion.div
className="flex items-center gap-3"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="i-ph:arrow-circle-up text-xl text-purple-500" />
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Updates</h3>
<p className="text-sm text-bolt-elements-textSecondary">Check for and manage application updates</p>
</div>
</motion.div>
<motion.div
className="flex flex-col gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm text-bolt-elements-textSecondary">
Currently on {isLatestBranch ? 'main' : 'stable'} branch
</span>
{updateInfo && (
<span className="text-xs text-bolt-elements-textTertiary">Version: {updateInfo.currentVersion}</span>
)}
</div>
<button
onClick={checkForUpdates}
disabled={isChecking}
className={classNames(
'px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-3',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
'transition-all duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
<div className="flex items-center gap-2">
<div className={classNames('i-ph:arrows-clockwise', isChecking ? 'animate-spin' : '')} />
{isChecking ? 'Checking...' : 'Check for Updates'}
</div>
</button>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 text-red-700 dark:bg-red-900/20 dark:border-red-900/50 dark:text-red-400">
<div className="flex items-center gap-2">
<div className="i-ph:warning-circle" />
{error}
</div>
</div>
)}
{updateInfo && (
<div
className={classNames(
'p-4 rounded-lg border',
updateInfo.hasUpdate
? 'bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-900/50'
: 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-900/50',
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<span
className={classNames(
'text-lg',
updateInfo.hasUpdate
? 'i-ph:warning text-yellow-600 dark:text-yellow-400'
: 'i-ph:check-circle text-green-600 dark:text-green-400',
)}
/>
<div>
<h3
className={classNames(
'text-sm font-medium',
updateInfo.hasUpdate
? 'text-yellow-900 dark:text-yellow-300'
: 'text-green-900 dark:text-green-300',
)}
>
{updateInfo.hasUpdate ? 'Update Available' : 'Up to Date'}
</h3>
<p className="text-sm text-bolt-elements-textSecondary mt-1">
{updateInfo.hasUpdate
? `A new version is available on the ${updateInfo.branch} branch`
: 'You are running the latest version'}
</p>
{updateInfo.hasUpdate && (
<div className="mt-2 flex flex-col gap-1 text-xs text-bolt-elements-textTertiary">
<p>Current Version: {updateInfo.currentVersion}</p>
<p>Latest Version: {updateInfo.latestVersion}</p>
<p>Branch: {updateInfo.branch}</p>
</div>
)}
</div>
</div>
{updateInfo.hasUpdate && (
<button
onClick={handleViewChanges}
className="shrink-0 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:hover:bg-blue-900/30"
>
<span className="i-ph:git-branch text-lg" />
View Changes
</button>
)}
</div>
</div>
)}
</motion.div>
</div>
);
};
export default UpdateTab;

View File

@ -0,0 +1,25 @@
import type { Dispatch, SetStateAction } from 'react';
import type { TabType, TabVisibilityConfig } from '~/components/settings/settings.types';
export interface ProfileHeaderProps {
onNavigate: Dispatch<SetStateAction<TabType | null>>;
visibleTabs: TabVisibilityConfig[];
}
export { type TabType };
export const ProfileHeader = ({ onNavigate, visibleTabs }: ProfileHeaderProps) => {
return (
<div className="flex items-center gap-2">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => onNavigate(tab.id)}
className="text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
>
{tab.id}
</button>
))}
</div>
);
};

View File

@ -0,0 +1,385 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { classNames } from '~/utils/classNames';
import { DialogTitle } from '~/components/ui/Dialog';
import { Switch } from '~/components/ui/Switch';
import type { TabType, TabVisibilityConfig } from '~/components/settings/settings.types';
import { TAB_LABELS } from '~/components/settings/settings.types';
import { DeveloperWindow } from '~/components/settings/developer/DeveloperWindow';
import { TabTile } from '~/components/settings/shared/TabTile';
import { tabConfigurationStore, updateTabConfiguration } from '~/lib/stores/settings';
import { useStore } from '@nanostores/react';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import ProfileTab from '~/components/settings/profile/ProfileTab';
import SettingsTab from '~/components/settings/settings/SettingsTab';
import NotificationsTab from '~/components/settings/notifications/NotificationsTab';
import FeaturesTab from '~/components/settings/features/FeaturesTab';
import DataTab from '~/components/settings/data/DataTab';
import { ProvidersTab } from '~/components/settings/providers/ProvidersTab';
import DebugTab from '~/components/settings/debug/DebugTab';
import { EventLogsTab } from '~/components/settings/event-logs/EventLogsTab';
import UpdateTab from '~/components/settings/update/UpdateTab';
import ConnectionsTab from '~/components/settings/connections/ConnectionsTab';
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';
interface DraggableTabTileProps {
tab: TabVisibilityConfig;
index: number;
moveTab: (dragIndex: number, hoverIndex: number) => void;
onClick: () => void;
isActive: boolean;
hasUpdate: boolean;
statusMessage: string;
description: string;
isLoading?: boolean;
}
const TAB_DESCRIPTIONS: Record<TabType, string> = {
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',
providers: 'Configure AI providers and models',
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',
};
const DraggableTabTile = ({
tab,
index,
moveTab,
onClick,
isActive,
hasUpdate,
statusMessage,
description,
isLoading,
}: DraggableTabTileProps) => {
const [{ isDragging }, drag] = useDrag({
type: 'tab',
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [, drop] = useDrop({
accept: 'tab',
hover: (item: { index: number }) => {
if (item.index === index) {
return;
}
moveTab(item.index, index);
item.index = index;
},
});
return (
<div ref={(node) => drag(drop(node))} style={{ opacity: isDragging ? 0.5 : 1 }}>
<TabTile
tab={tab}
onClick={onClick}
isActive={isActive}
hasUpdate={hasUpdate}
statusMessage={statusMessage}
description={description}
isLoading={isLoading}
/>
</div>
);
};
interface UsersWindowProps {
open: boolean;
onClose: () => void;
}
export const UsersWindow = ({ open, onClose }: UsersWindowProps) => {
const [developerMode, setDeveloperMode] = useState(false);
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
const tabConfiguration = useStore(tabConfigurationStore);
// 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();
const handleDeveloperModeChange = (checked: boolean) => {
setDeveloperMode(checked);
};
const handleBack = () => {
setActiveTab(null);
};
// Only show tabs that are assigned to the user window AND are visible
const visibleUserTabs = tabConfiguration.userTabs
.filter((tab: TabVisibilityConfig) => tab.window === 'user' && tab.visible)
.sort((a: TabVisibilityConfig, b: TabVisibilityConfig) => (a.order || 0) - (b.order || 0));
const moveTab = (dragIndex: number, hoverIndex: number) => {
const draggedTab = visibleUserTabs[dragIndex];
const targetTab = visibleUserTabs[hoverIndex];
// Update the order of the dragged and target tabs
const updatedDraggedTab = { ...draggedTab, order: targetTab.order };
const updatedTargetTab = { ...targetTab, order: draggedTab.order };
// Update both tabs in the store
updateTabConfiguration(updatedDraggedTab);
updateTabConfiguration(updatedTargetTab);
};
const handleTabClick = async (tabId: TabType) => {
setLoadingTab(tabId);
setActiveTab(tabId);
// Acknowledge the status based on tab type
switch (tabId) {
case 'update':
await acknowledgeUpdate();
break;
case 'features':
await acknowledgeAllFeatures();
break;
case 'notifications':
await markAllAsRead();
break;
case 'connection':
acknowledgeIssue();
break;
case 'debug':
await acknowledgeAllIssues();
break;
}
// Simulate loading time (remove this in production)
await new Promise((resolve) => setTimeout(resolve, 1000));
setLoadingTab(null);
};
const getTabComponent = () => {
switch (activeTab) {
case 'profile':
return <ProfileTab />;
case 'settings':
return <SettingsTab />;
case 'notifications':
return <NotificationsTab />;
case 'features':
return <FeaturesTab />;
case 'data':
return <DataTab />;
case 'providers':
return <ProvidersTab />;
case 'connection':
return <ConnectionsTab />;
case 'debug':
return <DebugTab />;
case 'event-logs':
return <EventLogsTab />;
case 'update':
return <UpdateTab />;
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 '';
}
};
return (
<>
<DeveloperWindow open={developerMode} onClose={() => setDeveloperMode(false)} />
<DndProvider backend={HTML5Backend}>
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[50]">
<RadixDialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</RadixDialog.Overlay>
<RadixDialog.Content aria-describedby={undefined} asChild>
<motion.div
className={classNames(
'relative',
'w-[1200px] h-[90vh]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-2xl shadow-2xl',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'flex flex-col overflow-hidden',
'z-[51]',
)}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
{/* Header */}
<div className="flex-none flex items-center justify-between px-6 py-4 border-b border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center gap-3">
{activeTab ? (
<motion.button
onClick={handleBack}
className={classNames(
'flex items-center justify-center w-8 h-8 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
'group transition-all duration-200',
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<div className="i-ph:arrow-left w-4 h-4 text-bolt-elements-textSecondary group-hover:text-purple-500 transition-colors" />
</motion.button>
) : (
<motion.div
className="i-ph:lightning-fill w-5 h-5 text-purple-500"
initial={{ rotate: -10 }}
animate={{ rotate: 10 }}
transition={{
repeat: Infinity,
repeatType: 'reverse',
duration: 2,
ease: 'easeInOut',
}}
/>
)}
<DialogTitle className="text-lg font-medium text-bolt-elements-textPrimary">
{activeTab ? TAB_LABELS[activeTab] : 'Bolt Control Panel'}
</DialogTitle>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch
checked={developerMode}
onCheckedChange={handleDeveloperModeChange}
className="data-[state=checked]:bg-purple-500"
aria-label="Toggle developer mode"
/>
<label className="text-sm text-bolt-elements-textSecondary">Developer Mode</label>
</div>
<motion.button
onClick={onClose}
className={classNames(
'flex items-center justify-center w-8 h-8 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
'group transition-all duration-200',
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<div className="i-ph:x w-4 h-4 text-bolt-elements-textSecondary group-hover:text-purple-500 transition-colors" />
</motion.button>
</div>
</div>
{/* Content */}
<div
className={classNames(
'flex-1',
'overflow-y-auto',
'hover:overflow-y-auto',
'scrollbar scrollbar-w-2',
'scrollbar-track-transparent',
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
'will-change-scroll',
'touch-auto',
)}
>
<motion.div
key={activeTab || 'home'}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="p-6"
>
{activeTab ? (
getTabComponent()
) : (
<div className="grid grid-cols-4 gap-4">
{visibleUserTabs.map((tab: TabVisibilityConfig, index: number) => (
<DraggableTabTile
key={tab.id}
tab={tab}
index={index}
moveTab={moveTab}
onClick={() => handleTabClick(tab.id)}
isActive={activeTab === tab.id}
hasUpdate={getTabUpdateStatus(tab.id)}
statusMessage={getStatusMessage(tab.id)}
description={TAB_DESCRIPTIONS[tab.id]}
isLoading={loadingTab === tab.id}
/>
))}
</div>
)}
</motion.div>
</div>
</motion.div>
</RadixDialog.Content>
</div>
</RadixDialog.Portal>
</RadixDialog.Root>
</DndProvider>
</>
);
};

View File

@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
import { SettingsWindow } from '~/components/settings/SettingsWindow';
import { UsersWindow } from '~/components/settings/user/UsersWindow';
import { SettingsButton } from '~/components/ui/SettingsButton';
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
import { cubicEasingFn } from '~/utils/easings';
@ -226,7 +226,7 @@ export const Menu = () => {
<ThemeSwitch />
</div>
</div>
<SettingsWindow open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
<UsersWindow open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
</motion.div>
);
};

View File

@ -0,0 +1,63 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { type ReactNode } from 'react';
import { classNames } from '~/utils/classNames';
interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
align?: 'start' | 'center' | 'end';
sideOffset?: number;
}
interface DropdownItemProps {
children: ReactNode;
onSelect?: () => void;
className?: string;
}
export const DropdownItem = ({ children, onSelect, className }: DropdownItemProps) => (
<DropdownMenu.Item
className={classNames(
'relative flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
'text-bolt-elements-textPrimary hover:text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-3',
'transition-colors cursor-pointer',
'outline-none',
className,
)}
onSelect={onSelect}
>
{children}
</DropdownMenu.Item>
);
export const DropdownSeparator = () => <DropdownMenu.Separator className="h-px bg-bolt-elements-borderColor my-1" />;
export const Dropdown = ({ trigger, children, align = 'end', sideOffset = 5 }: DropdownProps) => {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>{trigger}</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={classNames(
'min-w-[220px] rounded-lg p-2',
'bg-bolt-elements-background-depth-2',
'border border-bolt-elements-borderColor',
'shadow-lg',
'animate-in fade-in-80 zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
'z-[1000]',
)}
sideOffset={sideOffset}
align={align}
>
{children}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@ -11,13 +11,14 @@ interface WindowSize {
name: string;
width: number;
height: number;
icon: string;
}
const WINDOW_SIZES: WindowSize[] = [
{ name: 'Mobile (375x667)', width: 375, height: 667 },
{ name: 'Tablet (768x1024)', width: 768, height: 1024 },
{ name: 'Laptop (1366x768)', width: 1366, height: 768 },
{ name: 'Desktop (1920x1080)', width: 1920, height: 1080 },
{ name: 'Mobile', width: 375, height: 667, icon: 'i-ph:device-mobile' },
{ name: 'Tablet', width: 768, height: 1024, icon: 'i-ph:device-tablet' },
{ name: 'Laptop', width: 1366, height: 768, icon: 'i-ph:laptop' },
{ name: 'Desktop', width: 1920, height: 1080, icon: 'i-ph:monitor' },
];
export const Preview = memo(() => {
@ -249,14 +250,17 @@ export const Preview = memo(() => {
{isPortDropdownOpen && (
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
)}
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
<IconButton
icon="i-ph:selection"
onClick={() => setIsSelectionMode(!isSelectionMode)}
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
/>
<div className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive">
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-2">
<div className="flex items-center gap-2">
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
<IconButton
icon="i-ph:selection"
onClick={() => setIsSelectionMode(!isSelectionMode)}
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
/>
</div>
<div className="flex-grow flex items-center gap-1 bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive">
<input
title="URL"
ref={inputRef}
@ -278,68 +282,80 @@ export const Preview = memo(() => {
/>
</div>
{previews.length > 1 && (
<PortDropdown
activePreviewIndex={activePreviewIndex}
setActivePreviewIndex={setActivePreviewIndex}
isDropdownOpen={isPortDropdownOpen}
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
setIsDropdownOpen={setIsPortDropdownOpen}
previews={previews}
/>
)}
<IconButton
icon="i-ph:devices"
onClick={toggleDeviceMode}
title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
/>
<IconButton
icon="i-ph:layout-light"
onClick={() => setIsPreviewOnly(!isPreviewOnly)}
title={isPreviewOnly ? 'Show Full Interface' : 'Show Preview Only'}
/>
<IconButton
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
/>
<div className="relative">
<IconButton
icon="i-ph:arrow-square-out"
onClick={() => openInNewWindow(selectedWindowSize)}
title={`Open Preview in ${selectedWindowSize.name} Window`}
/>
<IconButton
icon="i-ph:caret-down"
onClick={() => setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)}
className="ml-1"
title="Select Window Size"
/>
{isWindowSizeDropdownOpen && (
<>
<div className="fixed inset-0 z-50" onClick={() => setIsWindowSizeDropdownOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-50 bg-bolt-elements-background-depth-2 rounded-lg shadow-lg border border-bolt-elements-borderColor overflow-hidden">
{WINDOW_SIZES.map((size) => (
<button
key={size.name}
className="w-full px-4 py-2 text-left hover:bg-bolt-elements-background-depth-3 text-sm whitespace-nowrap"
onClick={() => {
setSelectedWindowSize(size);
setIsWindowSizeDropdownOpen(false);
openInNewWindow(size);
}}
>
{size.name}
</button>
))}
</div>
</>
<div className="flex items-center gap-2">
{previews.length > 1 && (
<PortDropdown
activePreviewIndex={activePreviewIndex}
setActivePreviewIndex={setActivePreviewIndex}
isDropdownOpen={isPortDropdownOpen}
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
setIsDropdownOpen={setIsPortDropdownOpen}
previews={previews}
/>
)}
<IconButton
icon="i-ph:devices"
onClick={toggleDeviceMode}
title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
/>
<IconButton
icon="i-ph:layout-light"
onClick={() => setIsPreviewOnly(!isPreviewOnly)}
title={isPreviewOnly ? 'Show Full Interface' : 'Show Preview Only'}
/>
<IconButton
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
/>
<div className="flex items-center relative">
<IconButton
icon="i-ph:arrow-square-out"
onClick={() => openInNewWindow(selectedWindowSize)}
title={`Open Preview in ${selectedWindowSize.name} Window`}
/>
<IconButton
icon="i-ph:caret-down"
onClick={() => setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)}
className="ml-1"
title="Select Window Size"
/>
{isWindowSizeDropdownOpen && (
<>
<div className="fixed inset-0 z-50" onClick={() => setIsWindowSizeDropdownOpen(false)} />
<div className="absolute right-0 top-full mt-2 z-50 min-w-[240px] bg-white dark:bg-black rounded-xl shadow-2xl border border-[#E5E7EB] dark:border-[rgba(255,255,255,0.1)] overflow-hidden">
{WINDOW_SIZES.map((size) => (
<button
key={size.name}
className="w-full px-4 py-3.5 text-left text-[#111827] dark:text-gray-300 text-sm whitespace-nowrap flex items-center gap-3 group hover:bg-[#F5EEFF] dark:hover:bg-gray-900 bg-white dark:bg-black"
onClick={() => {
setSelectedWindowSize(size);
setIsWindowSizeDropdownOpen(false);
openInNewWindow(size);
}}
>
<div
className={`${size.icon} w-5 h-5 text-[#6B7280] dark:text-gray-400 group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200`}
/>
<div className="flex flex-col">
<span className="font-medium group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200">
{size.name}
</span>
<span className="text-xs text-[#6B7280] dark:text-gray-400 group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200">
{size.width} × {size.height}
</span>
</div>
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
@ -349,7 +365,7 @@ export const Preview = memo(() => {
width: isDeviceModeOn ? `${widthPercent}%` : '100%',
height: '100%',
overflow: 'visible',
background: '#fff',
background: 'var(--bolt-elements-background-depth-1)',
position: 'relative',
display: 'flex',
}}
@ -359,7 +375,7 @@ export const Preview = memo(() => {
<iframe
ref={iframeRef}
title="preview"
className="border-none w-full h-full bg-white"
className="border-none w-full h-full bg-bolt-elements-background-depth-1"
src={iframeUrl}
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
allow="cross-origin-isolated"
@ -371,7 +387,9 @@ export const Preview = memo(() => {
/>
</>
) : (
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary">
No preview available
</div>
)}
{isDeviceModeOn && (

18
app/lib/api/connection.ts Normal file
View File

@ -0,0 +1,18 @@
export interface ConnectionStatus {
connected: boolean;
latency: number;
lastChecked: string;
}
export const checkConnection = async (): Promise<ConnectionStatus> => {
/*
* TODO: Implement actual connection check logic
* This is a mock implementation
*/
const connected = Math.random() > 0.1; // 90% chance of being connected
return {
connected,
latency: connected ? Math.floor(Math.random() * 1500) : 0, // Random latency between 0-1500ms
lastChecked: new Date().toISOString(),
};
};

65
app/lib/api/debug.ts Normal file
View File

@ -0,0 +1,65 @@
export interface DebugWarning {
id: string;
message: string;
timestamp: string;
code: string;
}
export interface DebugError {
id: string;
message: string;
timestamp: string;
stack?: string;
}
export interface DebugStatus {
warnings: DebugWarning[];
errors: DebugError[];
lastChecked: string;
}
export interface DebugIssue {
id: string;
type: 'warning' | 'error';
message: string;
}
export const getDebugStatus = async (): Promise<DebugStatus> => {
/*
* TODO: Implement actual debug status logic
* This is a mock implementation
*/
return {
warnings: [
{
id: 'warn-1',
message: 'High memory usage detected',
timestamp: new Date().toISOString(),
code: 'MEM_HIGH',
},
],
errors: [
{
id: 'err-1',
message: 'Failed to connect to database',
timestamp: new Date().toISOString(),
stack: 'Error: Connection timeout',
},
],
lastChecked: new Date().toISOString(),
};
};
export const acknowledgeWarning = async (warningId: string): Promise<void> => {
/*
* TODO: Implement actual warning acknowledgment logic
*/
console.log(`Acknowledging warning ${warningId}`);
};
export const acknowledgeError = async (errorId: string): Promise<void> => {
/*
* TODO: Implement actual error acknowledgment logic
*/
console.log(`Acknowledging error ${errorId}`);
};

35
app/lib/api/features.ts Normal file
View File

@ -0,0 +1,35 @@
export interface Feature {
id: string;
name: string;
description: string;
viewed: boolean;
releaseDate: string;
}
export const getFeatureFlags = async (): Promise<Feature[]> => {
/*
* TODO: Implement actual feature flags logic
* This is a mock implementation
*/
return [
{
id: 'feature-1',
name: 'Dark Mode',
description: 'Enable dark mode for better night viewing',
viewed: true,
releaseDate: '2024-03-15',
},
{
id: 'feature-2',
name: 'Tab Management',
description: 'Customize your tab layout',
viewed: false,
releaseDate: '2024-03-20',
},
];
};
export const markFeatureViewed = async (featureId: string): Promise<void> => {
/* TODO: Implement actual feature viewed logic */
console.log(`Marking feature ${featureId} as viewed`);
};

View File

@ -0,0 +1,40 @@
export interface Notification {
id: string;
title: string;
message: string;
type: 'info' | 'warning' | 'error' | 'success';
read: boolean;
timestamp: string;
}
export const getNotifications = async (): Promise<Notification[]> => {
/*
* TODO: Implement actual notifications logic
* This is a mock implementation
*/
return [
{
id: 'notif-1',
title: 'Welcome to Bolt',
message: 'Get started by exploring the features',
type: 'info',
read: true,
timestamp: new Date().toISOString(),
},
{
id: 'notif-2',
title: 'New Update Available',
message: 'Version 1.0.1 is now available',
type: 'info',
read: false,
timestamp: new Date().toISOString(),
},
];
};
export const markNotificationRead = async (notificationId: string): Promise<void> => {
/*
* TODO: Implement actual notification read logic
*/
console.log(`Marking notification ${notificationId} as read`);
};

28
app/lib/api/updates.ts Normal file
View File

@ -0,0 +1,28 @@
export interface UpdateCheckResult {
available: boolean;
version: string;
releaseNotes?: string;
}
export const checkForUpdates = async (): Promise<UpdateCheckResult> => {
/*
* TODO: Implement actual update check logic
* This is a mock implementation
*/
return {
available: Math.random() > 0.7, // 30% chance of update
version: '1.0.1',
releaseNotes: 'Bug fixes and performance improvements',
};
};
export const acknowledgeUpdate = async (version: string): Promise<void> => {
/*
* TODO: Implement actual update acknowledgment logic
* This is a mock implementation that would typically:
* 1. Store the acknowledged version in a persistent store
* 2. Update the UI state
* 3. Potentially send analytics
*/
console.log(`Acknowledging update version ${version}`);
};

View File

@ -4,3 +4,8 @@ export * from './useShortcuts';
export * from './useSnapScroll';
export * from './useEditChatDescription';
export { default } from './useViewport';
export { useUpdateCheck } from './useUpdateCheck';
export { useFeatures } from './useFeatures';
export { useNotifications } from './useNotifications';
export { useConnectionStatus } from './useConnectionStatus';
export { useDebugStatus } from './useDebugStatus';

View File

@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import { checkConnection } from '~/lib/api/connection';
const ACKNOWLEDGED_CONNECTION_ISSUE_KEY = 'bolt_acknowledged_connection_issue';
type ConnectionIssueType = 'disconnected' | 'high-latency' | null;
const getAcknowledgedIssue = (): string | null => {
try {
return localStorage.getItem(ACKNOWLEDGED_CONNECTION_ISSUE_KEY);
} catch {
return null;
}
};
export const useConnectionStatus = () => {
const [hasConnectionIssues, setHasConnectionIssues] = useState(false);
const [currentIssue, setCurrentIssue] = useState<ConnectionIssueType>(null);
const [acknowledgedIssue, setAcknowledgedIssue] = useState<string | null>(() => getAcknowledgedIssue());
const checkStatus = async () => {
try {
const status = await checkConnection();
const issue = !status.connected ? 'disconnected' : status.latency > 1000 ? 'high-latency' : null;
setCurrentIssue(issue);
// Only show issues if they're new or different from the acknowledged one
setHasConnectionIssues(issue !== null && issue !== acknowledgedIssue);
} catch (error) {
console.error('Failed to check connection:', error);
// Show connection issues if we can't even check the status
setCurrentIssue('disconnected');
setHasConnectionIssues(true);
}
};
useEffect(() => {
// Check immediately and then every 10 seconds
checkStatus();
const interval = setInterval(checkStatus, 10 * 1000);
return () => clearInterval(interval);
}, [acknowledgedIssue]);
const acknowledgeIssue = () => {
setAcknowledgedIssue(currentIssue);
setAcknowledgedIssue(currentIssue);
setHasConnectionIssues(false);
};
const resetAcknowledgment = () => {
setAcknowledgedIssue(null);
setAcknowledgedIssue(null);
checkStatus();
};
return { hasConnectionIssues, currentIssue, acknowledgeIssue, resetAcknowledgment };
};

View File

@ -0,0 +1,89 @@
import { useState, useEffect } from 'react';
import { getDebugStatus, acknowledgeWarning, acknowledgeError, type DebugIssue } from '~/lib/api/debug';
const ACKNOWLEDGED_DEBUG_ISSUES_KEY = 'bolt_acknowledged_debug_issues';
const getAcknowledgedIssues = (): string[] => {
try {
const stored = localStorage.getItem(ACKNOWLEDGED_DEBUG_ISSUES_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
};
const setAcknowledgedIssues = (issueIds: string[]) => {
try {
localStorage.setItem(ACKNOWLEDGED_DEBUG_ISSUES_KEY, JSON.stringify(issueIds));
} catch (error) {
console.error('Failed to persist acknowledged debug issues:', error);
}
};
export const useDebugStatus = () => {
const [hasActiveWarnings, setHasActiveWarnings] = useState(false);
const [activeIssues, setActiveIssues] = useState<DebugIssue[]>([]);
const [acknowledgedIssueIds, setAcknowledgedIssueIds] = useState<string[]>(() => getAcknowledgedIssues());
const checkDebugStatus = async () => {
try {
const status = await getDebugStatus();
const issues: DebugIssue[] = [
...status.warnings.map((w) => ({ ...w, type: 'warning' as const })),
...status.errors.map((e) => ({ ...e, type: 'error' as const })),
].filter((issue) => !acknowledgedIssueIds.includes(issue.id));
setActiveIssues(issues);
setHasActiveWarnings(issues.length > 0);
} catch (error) {
console.error('Failed to check debug status:', error);
}
};
useEffect(() => {
// Check immediately and then every 5 seconds
checkDebugStatus();
const interval = setInterval(checkDebugStatus, 5 * 1000);
return () => clearInterval(interval);
}, [acknowledgedIssueIds]);
const acknowledgeIssue = async (issue: DebugIssue) => {
try {
if (issue.type === 'warning') {
await acknowledgeWarning(issue.id);
} else {
await acknowledgeError(issue.id);
}
const newAcknowledgedIds = [...acknowledgedIssueIds, issue.id];
setAcknowledgedIssueIds(newAcknowledgedIds);
setAcknowledgedIssues(newAcknowledgedIds);
setActiveIssues((prev) => prev.filter((i) => i.id !== issue.id));
setHasActiveWarnings(activeIssues.length > 1);
} catch (error) {
console.error('Failed to acknowledge issue:', error);
}
};
const acknowledgeAllIssues = async () => {
try {
await Promise.all(
activeIssues.map((issue) =>
issue.type === 'warning' ? acknowledgeWarning(issue.id) : acknowledgeError(issue.id),
),
);
const newAcknowledgedIds = [...acknowledgedIssueIds, ...activeIssues.map((i) => i.id)];
setAcknowledgedIssueIds(newAcknowledgedIds);
setAcknowledgedIssues(newAcknowledgedIds);
setActiveIssues([]);
setHasActiveWarnings(false);
} catch (error) {
console.error('Failed to acknowledge all issues:', error);
}
};
return { hasActiveWarnings, activeIssues, acknowledgeIssue, acknowledgeAllIssues };
};

View File

@ -0,0 +1,72 @@
import { useState, useEffect } from 'react';
import { getFeatureFlags, markFeatureViewed, type Feature } from '~/lib/api/features';
const VIEWED_FEATURES_KEY = 'bolt_viewed_features';
const getViewedFeatures = (): string[] => {
try {
const stored = localStorage.getItem(VIEWED_FEATURES_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
};
const setViewedFeatures = (featureIds: string[]) => {
try {
localStorage.setItem(VIEWED_FEATURES_KEY, JSON.stringify(featureIds));
} catch (error) {
console.error('Failed to persist viewed features:', error);
}
};
export const useFeatures = () => {
const [hasNewFeatures, setHasNewFeatures] = useState(false);
const [unviewedFeatures, setUnviewedFeatures] = useState<Feature[]>([]);
const [viewedFeatureIds, setViewedFeatureIds] = useState<string[]>(() => getViewedFeatures());
useEffect(() => {
const checkNewFeatures = async () => {
try {
const features = await getFeatureFlags();
const unviewed = features.filter((feature) => !viewedFeatureIds.includes(feature.id));
setUnviewedFeatures(unviewed);
setHasNewFeatures(unviewed.length > 0);
} catch (error) {
console.error('Failed to check for new features:', error);
}
};
checkNewFeatures();
}, [viewedFeatureIds]);
const acknowledgeFeature = async (featureId: string) => {
try {
await markFeatureViewed(featureId);
const newViewedIds = [...viewedFeatureIds, featureId];
setViewedFeatureIds(newViewedIds);
setViewedFeatures(newViewedIds);
setUnviewedFeatures((prev) => prev.filter((feature) => feature.id !== featureId));
setHasNewFeatures(unviewedFeatures.length > 1);
} catch (error) {
console.error('Failed to acknowledge feature:', error);
}
};
const acknowledgeAllFeatures = async () => {
try {
await Promise.all(unviewedFeatures.map((feature) => markFeatureViewed(feature.id)));
const newViewedIds = [...viewedFeatureIds, ...unviewedFeatures.map((f) => f.id)];
setViewedFeatureIds(newViewedIds);
setViewedFeatures(newViewedIds);
setUnviewedFeatures([]);
setHasNewFeatures(false);
} catch (error) {
console.error('Failed to acknowledge all features:', error);
}
};
return { hasNewFeatures, unviewedFeatures, acknowledgeFeature, acknowledgeAllFeatures };
};

View File

@ -0,0 +1,77 @@
import { useState, useEffect } from 'react';
import { getNotifications, markNotificationRead, type Notification } from '~/lib/api/notifications';
const READ_NOTIFICATIONS_KEY = 'bolt_read_notifications';
const getReadNotifications = (): string[] => {
try {
const stored = localStorage.getItem(READ_NOTIFICATIONS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
};
const setReadNotifications = (notificationIds: string[]) => {
try {
localStorage.setItem(READ_NOTIFICATIONS_KEY, JSON.stringify(notificationIds));
} catch (error) {
console.error('Failed to persist read notifications:', error);
}
};
export const useNotifications = () => {
const [hasUnreadNotifications, setHasUnreadNotifications] = useState(false);
const [unreadNotifications, setUnreadNotifications] = useState<Notification[]>([]);
const [readNotificationIds, setReadNotificationIds] = useState<string[]>(() => getReadNotifications());
const checkNotifications = async () => {
try {
const notifications = await getNotifications();
const unread = notifications.filter((n) => !readNotificationIds.includes(n.id));
setUnreadNotifications(unread);
setHasUnreadNotifications(unread.length > 0);
} catch (error) {
console.error('Failed to check notifications:', error);
}
};
useEffect(() => {
// Check immediately and then every minute
checkNotifications();
const interval = setInterval(checkNotifications, 60 * 1000);
return () => clearInterval(interval);
}, [readNotificationIds]);
const markAsRead = async (notificationId: string) => {
try {
await markNotificationRead(notificationId);
const newReadIds = [...readNotificationIds, notificationId];
setReadNotificationIds(newReadIds);
setReadNotifications(newReadIds);
setUnreadNotifications((prev) => prev.filter((n) => n.id !== notificationId));
setHasUnreadNotifications(unreadNotifications.length > 1);
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const markAllAsRead = async () => {
try {
await Promise.all(unreadNotifications.map((n) => markNotificationRead(n.id)));
const newReadIds = [...readNotificationIds, ...unreadNotifications.map((n) => n.id)];
setReadNotificationIds(newReadIds);
setReadNotifications(newReadIds);
setUnreadNotifications([]);
setHasUnreadNotifications(false);
} catch (error) {
console.error('Failed to mark all notifications as read:', error);
}
};
return { hasUnreadNotifications, unreadNotifications, markAsRead, markAllAsRead };
};

View File

@ -0,0 +1,222 @@
import { useStore } from '@nanostores/react';
import {
isDebugMode,
isEventLogsEnabled,
isLocalModelsEnabled,
LOCAL_PROVIDERS,
promptStore,
providersStore,
latestBranchStore,
autoSelectStarterTemplate,
enableContextOptimizationStore,
tabConfigurationStore,
updateTabConfiguration as updateTabConfig,
resetTabConfiguration as resetTabConfig,
} from '~/lib/stores/settings';
import { useCallback, useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import type { IProviderSetting, ProviderInfo, IProviderConfig } from '~/types/model';
import type { TabWindowConfig, TabVisibilityConfig } from '~/components/settings/settings.types';
import { logStore } from '~/lib/stores/logs';
import { getLocalStorage, setLocalStorage } from '~/utils/localStorage';
export interface Settings {
theme: 'light' | 'dark' | 'system';
language: string;
notifications: boolean;
eventLogs: boolean;
timezone: string;
tabConfiguration: TabWindowConfig;
}
export interface UseSettingsReturn {
// Theme and UI settings
setTheme: (theme: Settings['theme']) => void;
setLanguage: (language: string) => void;
setNotifications: (enabled: boolean) => void;
setEventLogs: (enabled: boolean) => void;
setTimezone: (timezone: string) => void;
settings: Settings;
// Provider settings
providers: Record<string, IProviderConfig>;
activeProviders: ProviderInfo[];
updateProviderSettings: (provider: string, config: IProviderSetting) => void;
isLocalModel: boolean;
enableLocalModels: (enabled: boolean) => void;
// Debug and development settings
debug: boolean;
enableDebugMode: (enabled: boolean) => void;
eventLogs: boolean;
promptId: string;
setPromptId: (promptId: string) => void;
isLatestBranch: boolean;
enableLatestBranch: (enabled: boolean) => void;
autoSelectTemplate: boolean;
setAutoSelectTemplate: (enabled: boolean) => void;
contextOptimizationEnabled: boolean;
enableContextOptimization: (enabled: boolean) => void;
// Tab configuration
tabConfiguration: TabWindowConfig;
updateTabConfiguration: (config: TabVisibilityConfig) => void;
resetTabConfiguration: () => void;
}
export function useSettings(): UseSettingsReturn {
const providers = useStore(providersStore) as Record<string, IProviderConfig>;
const debug = useStore(isDebugMode);
const eventLogs = useStore(isEventLogsEnabled);
const promptId = useStore(promptStore);
const isLocalModel = useStore(isLocalModelsEnabled);
const isLatestBranch = useStore(latestBranchStore);
const autoSelectTemplate = useStore(autoSelectStarterTemplate);
const [activeProviders, setActiveProviders] = useState<ProviderInfo[]>([]);
const contextOptimizationEnabled = useStore(enableContextOptimizationStore);
const tabConfiguration = useStore(tabConfigurationStore);
const [settings, setSettings] = useState<Settings>(() => {
const storedSettings = getLocalStorage('settings');
return {
theme: storedSettings?.theme || 'system',
language: storedSettings?.language || 'en',
notifications: storedSettings?.notifications ?? true,
eventLogs: storedSettings?.eventLogs ?? true,
timezone: storedSettings?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
tabConfiguration,
};
});
// writing values to cookies on change
useEffect(() => {
const providers = providersStore.get();
const providerSetting: Record<string, IProviderSetting> = {};
Object.keys(providers).forEach((provider) => {
providerSetting[provider] = providers[provider].settings;
});
Cookies.set('providers', JSON.stringify(providerSetting));
}, [providers]);
useEffect(() => {
let active = Object.entries(providers)
.filter(([_key, provider]) => provider.settings.enabled)
.map(([_k, p]) => p);
if (!isLocalModel) {
active = active.filter((p) => !LOCAL_PROVIDERS.includes(p.name));
}
setActiveProviders(active);
}, [providers, isLocalModel]);
const saveSettings = useCallback((newSettings: Partial<Settings>) => {
setSettings((prev) => {
const updated = { ...prev, ...newSettings };
setLocalStorage('settings', updated);
return updated;
});
}, []);
const updateProviderSettings = useCallback((provider: string, config: IProviderSetting) => {
providersStore.setKey(provider, { settings: config } as IProviderConfig);
}, []);
const enableDebugMode = useCallback((enabled: boolean) => {
isDebugMode.set(enabled);
logStore.logSystem(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isDebugEnabled', String(enabled));
}, []);
const setEventLogs = useCallback((enabled: boolean) => {
isEventLogsEnabled.set(enabled);
logStore.logSystem(`Event logs ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isEventLogsEnabled', String(enabled));
}, []);
const enableLocalModels = useCallback((enabled: boolean) => {
isLocalModelsEnabled.set(enabled);
logStore.logSystem(`Local models ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isLocalModelsEnabled', String(enabled));
}, []);
const setPromptId = useCallback((promptId: string) => {
promptStore.set(promptId);
Cookies.set('promptId', promptId);
}, []);
const enableLatestBranch = useCallback((enabled: boolean) => {
latestBranchStore.set(enabled);
logStore.logSystem(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isLatestBranch', String(enabled));
}, []);
const setAutoSelectTemplate = useCallback((enabled: boolean) => {
autoSelectStarterTemplate.set(enabled);
logStore.logSystem(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('autoSelectTemplate', String(enabled));
}, []);
const enableContextOptimization = useCallback((enabled: boolean) => {
enableContextOptimizationStore.set(enabled);
logStore.logSystem(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('contextOptimizationEnabled', String(enabled));
}, []);
const setTheme = useCallback(
(theme: Settings['theme']) => {
saveSettings({ theme });
},
[saveSettings],
);
const setLanguage = useCallback(
(language: string) => {
saveSettings({ language });
},
[saveSettings],
);
const setNotifications = useCallback(
(enabled: boolean) => {
saveSettings({ notifications: enabled });
},
[saveSettings],
);
const setTimezone = useCallback(
(timezone: string) => {
saveSettings({ timezone });
},
[saveSettings],
);
return {
...settings,
providers,
activeProviders,
updateProviderSettings,
isLocalModel,
enableLocalModels,
debug,
enableDebugMode,
eventLogs,
setEventLogs,
promptId,
setPromptId,
isLatestBranch,
enableLatestBranch,
autoSelectTemplate,
setAutoSelectTemplate,
contextOptimizationEnabled,
enableContextOptimization,
setTheme,
setLanguage,
setNotifications,
setTimezone,
settings,
tabConfiguration,
updateTabConfiguration: updateTabConfig,
resetTabConfiguration: resetTabConfig,
};
}

View File

@ -1,229 +0,0 @@
import { useStore } from '@nanostores/react';
import {
isDebugMode,
isEventLogsEnabled,
isLocalModelsEnabled,
LOCAL_PROVIDERS,
promptStore,
providersStore,
latestBranchStore,
autoSelectStarterTemplate,
enableContextOptimizationStore,
} from '~/lib/stores/settings';
import { useCallback, useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import type { IProviderSetting, ProviderInfo } from '~/types/model';
import { logStore } from '~/lib/stores/logs'; // assuming logStore is imported from this location
interface CommitData {
commit: string;
version?: string;
}
const versionData: CommitData = {
commit: __COMMIT_HASH,
version: __APP_VERSION,
};
export function useSettings() {
const providers = useStore(providersStore);
const debug = useStore(isDebugMode);
const eventLogs = useStore(isEventLogsEnabled);
const promptId = useStore(promptStore);
const isLocalModel = useStore(isLocalModelsEnabled);
const isLatestBranch = useStore(latestBranchStore);
const autoSelectTemplate = useStore(autoSelectStarterTemplate);
const [activeProviders, setActiveProviders] = useState<ProviderInfo[]>([]);
const contextOptimizationEnabled = useStore(enableContextOptimizationStore);
// Function to check if we're on stable version
const checkIsStableVersion = async () => {
try {
const response = await fetch(
`https://api.github.com/repos/stackblitz-labs/bolt.diy/git/refs/tags/v${versionData.version}`,
);
const data: { object: { sha: string } } = await response.json();
return versionData.commit.slice(0, 7) === data.object.sha.slice(0, 7);
} catch (error) {
console.warn('Error checking stable version:', error);
return false;
}
};
// reading values from cookies on mount
useEffect(() => {
const savedProviders = Cookies.get('providers');
if (savedProviders) {
try {
const parsedProviders: Record<string, IProviderSetting> = JSON.parse(savedProviders);
Object.keys(providers).forEach((provider) => {
const currentProviderSettings = parsedProviders[provider];
if (currentProviderSettings) {
providersStore.setKey(provider, {
...providers[provider],
settings: {
...currentProviderSettings,
enabled: currentProviderSettings.enabled ?? true,
},
});
}
});
} catch (error) {
console.error('Failed to parse providers from cookies:', error);
}
}
// load debug mode from cookies
const savedDebugMode = Cookies.get('isDebugEnabled');
if (savedDebugMode) {
isDebugMode.set(savedDebugMode === 'true');
}
// load event logs from cookies
const savedEventLogs = Cookies.get('isEventLogsEnabled');
if (savedEventLogs) {
isEventLogsEnabled.set(savedEventLogs === 'true');
}
// load local models from cookies
const savedLocalModels = Cookies.get('isLocalModelsEnabled');
if (savedLocalModels) {
isLocalModelsEnabled.set(savedLocalModels === 'true');
}
const promptId = Cookies.get('promptId');
if (promptId) {
promptStore.set(promptId);
}
// load latest branch setting from cookies or determine based on version
const savedLatestBranch = Cookies.get('isLatestBranch');
let checkCommit = Cookies.get('commitHash');
if (checkCommit === undefined) {
checkCommit = versionData.commit;
}
if (savedLatestBranch === undefined || checkCommit !== versionData.commit) {
// If setting hasn't been set by user, check version
checkIsStableVersion().then((isStable) => {
const shouldUseLatest = !isStable;
latestBranchStore.set(shouldUseLatest);
Cookies.set('isLatestBranch', String(shouldUseLatest));
Cookies.set('commitHash', String(versionData.commit));
});
} else {
latestBranchStore.set(savedLatestBranch === 'true');
}
const autoSelectTemplate = Cookies.get('autoSelectTemplate');
if (autoSelectTemplate) {
autoSelectStarterTemplate.set(autoSelectTemplate === 'true');
}
const savedContextOptimizationEnabled = Cookies.get('contextOptimizationEnabled');
if (savedContextOptimizationEnabled) {
enableContextOptimizationStore.set(savedContextOptimizationEnabled === 'true');
}
}, []);
// writing values to cookies on change
useEffect(() => {
const providers = providersStore.get();
const providerSetting: Record<string, IProviderSetting> = {};
Object.keys(providers).forEach((provider) => {
providerSetting[provider] = providers[provider].settings;
});
Cookies.set('providers', JSON.stringify(providerSetting));
}, [providers]);
useEffect(() => {
let active = Object.entries(providers)
.filter(([_key, provider]) => provider.settings.enabled)
.map(([_k, p]) => p);
if (!isLocalModel) {
active = active.filter((p) => !LOCAL_PROVIDERS.includes(p.name));
}
setActiveProviders(active);
}, [providers, isLocalModel]);
// helper function to update settings
const updateProviderSettings = useCallback(
(provider: string, config: IProviderSetting) => {
const settings = providers[provider].settings;
providersStore.setKey(provider, { ...providers[provider], settings: { ...settings, ...config } });
},
[providers],
);
const enableDebugMode = useCallback((enabled: boolean) => {
isDebugMode.set(enabled);
logStore.logSystem(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isDebugEnabled', String(enabled));
}, []);
const enableEventLogs = useCallback((enabled: boolean) => {
isEventLogsEnabled.set(enabled);
logStore.logSystem(`Event logs ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isEventLogsEnabled', String(enabled));
}, []);
const enableLocalModels = useCallback((enabled: boolean) => {
isLocalModelsEnabled.set(enabled);
logStore.logSystem(`Local models ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isLocalModelsEnabled', String(enabled));
}, []);
const setPromptId = useCallback((promptId: string) => {
promptStore.set(promptId);
Cookies.set('promptId', promptId);
}, []);
const enableLatestBranch = useCallback((enabled: boolean) => {
latestBranchStore.set(enabled);
logStore.logSystem(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isLatestBranch', String(enabled));
}, []);
const setAutoSelectTemplate = useCallback((enabled: boolean) => {
autoSelectStarterTemplate.set(enabled);
logStore.logSystem(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('autoSelectTemplate', String(enabled));
}, []);
const enableContextOptimization = useCallback((enabled: boolean) => {
enableContextOptimizationStore.set(enabled);
logStore.logSystem(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('contextOptimizationEnabled', String(enabled));
}, []);
return {
providers,
activeProviders,
updateProviderSettings,
debug,
enableDebugMode,
eventLogs,
enableEventLogs,
isLocalModel,
enableLocalModels,
promptId,
setPromptId,
isLatestBranch,
enableLatestBranch,
autoSelectTemplate,
setAutoSelectTemplate,
contextOptimizationEnabled,
enableContextOptimization,
};
}

View File

@ -0,0 +1,58 @@
import { useState, useEffect } from 'react';
import { checkForUpdates, acknowledgeUpdate } from '~/lib/api/updates';
const LAST_ACKNOWLEDGED_VERSION_KEY = 'bolt_last_acknowledged_version';
export const useUpdateCheck = () => {
const [hasUpdate, setHasUpdate] = useState(false);
const [currentVersion, setCurrentVersion] = useState<string>('');
const [lastAcknowledgedVersion, setLastAcknowledgedVersion] = useState<string | null>(() => {
try {
return localStorage.getItem(LAST_ACKNOWLEDGED_VERSION_KEY);
} catch {
return null;
}
});
useEffect(() => {
const checkUpdate = async () => {
try {
const { available, version } = await checkForUpdates();
setCurrentVersion(version);
// Only show update if it's a new version and hasn't been acknowledged
setHasUpdate(available && version !== lastAcknowledgedVersion);
} catch (error) {
console.error('Failed to check for updates:', error);
}
};
// Check immediately and then every 30 minutes
checkUpdate();
const interval = setInterval(checkUpdate, 30 * 60 * 1000);
return () => clearInterval(interval);
}, [lastAcknowledgedVersion]);
const handleAcknowledgeUpdate = async () => {
try {
const { version } = await checkForUpdates();
await acknowledgeUpdate(version);
// Store in localStorage
try {
localStorage.setItem(LAST_ACKNOWLEDGED_VERSION_KEY, version);
} catch (error) {
console.error('Failed to persist acknowledged version:', error);
}
setLastAcknowledgedVersion(version);
setHasUpdate(false);
} catch (error) {
console.error('Failed to acknowledge update:', error);
}
};
return { hasUpdate, currentVersion, acknowledgeUpdate: handleAcknowledgeUpdate };
};

View File

@ -11,7 +11,8 @@ export default class GithubProvider extends BaseProvider {
config = {
apiTokenKey: 'GITHUB_API_KEY',
};
// find more in https://github.com/marketplace?type=models
// find more in https://github.com/marketplace?type=models
staticModels: ModelInfo[] = [
{ name: 'gpt-4o', label: 'GPT-4o', provider: 'Github', maxTokenAllowed: 8000 },
{ name: 'o1', label: 'o1-preview', provider: 'Github', maxTokenAllowed: 100000 },

View File

@ -10,7 +10,10 @@ export interface LogEntry {
level: 'info' | 'warning' | 'error' | 'debug';
message: string;
details?: Record<string, any>;
category: 'system' | 'provider' | 'user' | 'error';
category: 'system' | 'provider' | 'user' | 'error' | 'api' | 'auth' | 'database' | 'network';
subCategory?: string;
duration?: number;
statusCode?: number;
}
const MAX_LOGS = 1000; // Maximum number of logs to keep in memory
@ -101,18 +104,76 @@ class LogStore {
return this.addLog(message, 'info', 'user', details);
}
// API Connection Logging
logAPIRequest(endpoint: string, method: string, duration: number, statusCode: number, details?: Record<string, any>) {
const message = `${method} ${endpoint} - ${statusCode} (${duration}ms)`;
const level = statusCode >= 400 ? 'error' : statusCode >= 300 ? 'warning' : 'info';
return this.addLog(message, level, 'api', {
...details,
endpoint,
method,
duration,
statusCode,
timestamp: new Date().toISOString(),
});
}
// Authentication Logging
logAuth(
action: 'login' | 'logout' | 'token_refresh' | 'key_validation',
success: boolean,
details?: Record<string, any>,
) {
const message = `Auth ${action} - ${success ? 'Success' : 'Failed'}`;
const level = success ? 'info' : 'error';
return this.addLog(message, level, 'auth', {
...details,
action,
success,
timestamp: new Date().toISOString(),
});
}
// Network Status Logging
logNetworkStatus(status: 'online' | 'offline' | 'reconnecting' | 'connected', details?: Record<string, any>) {
const message = `Network ${status}`;
const level = status === 'offline' ? 'error' : status === 'reconnecting' ? 'warning' : 'info';
return this.addLog(message, level, 'network', {
...details,
status,
timestamp: new Date().toISOString(),
});
}
// Database Operations Logging
logDatabase(operation: string, success: boolean, duration: number, details?: Record<string, any>) {
const message = `DB ${operation} - ${success ? 'Success' : 'Failed'} (${duration}ms)`;
const level = success ? 'info' : 'error';
return this.addLog(message, level, 'database', {
...details,
operation,
success,
duration,
timestamp: new Date().toISOString(),
});
}
// Error events
logError(message: string, error?: Error | unknown, details?: Record<string, any>) {
const errorDetails = {
...(details || {}),
error:
error instanceof Error
? {
message: error.message,
stack: error.stack,
}
: error,
};
const errorDetails =
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
...details,
}
: { error, ...details };
return this.addLog(message, 'error', 'error', errorDetails);
}

View File

@ -2,6 +2,9 @@ import { atom, map } from 'nanostores';
import { workbenchStore } from './workbench';
import { PROVIDER_LIST } from '~/utils/constants';
import type { IProviderConfig } from '~/types/model';
import type { TabVisibilityConfig, TabWindowConfig } from '~/components/settings/settings.types';
import { DEFAULT_TAB_CONFIG } from '~/components/settings/settings.types';
import Cookies from 'js-cookie';
export interface Shortcut {
key: string;
@ -46,7 +49,9 @@ export const providersStore = map<ProviderSetting>(initialProviderSettings);
export const isDebugMode = atom(false);
export const isEventLogsEnabled = atom(false);
// Initialize event logs from cookie or default to false
const savedEventLogs = Cookies.get('isEventLogsEnabled');
export const isEventLogsEnabled = atom(savedEventLogs === 'true');
export const isLocalModelsEnabled = atom(true);
@ -56,3 +61,48 @@ export const latestBranchStore = atom(false);
export const autoSelectStarterTemplate = atom(false);
export const enableContextOptimizationStore = atom(false);
// Initialize tab configuration from cookie or default
const savedTabConfig = Cookies.get('tabConfiguration');
const initialTabConfig: TabWindowConfig = savedTabConfig
? JSON.parse(savedTabConfig)
: {
userTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'developer'),
};
export const tabConfigurationStore = map<TabWindowConfig>(initialTabConfig);
// Helper function to update tab configuration
export const updateTabConfiguration = (config: TabVisibilityConfig) => {
const currentConfig = tabConfigurationStore.get();
const isUserTab = config.window === 'user';
const targetArray = isUserTab ? 'userTabs' : 'developerTabs';
// Only update the tab in its respective window
const updatedTabs = currentConfig[targetArray].map((tab) => (tab.id === config.id ? { ...config } : tab));
// If tab doesn't exist in this window yet, add it
if (!updatedTabs.find((tab) => tab.id === config.id)) {
updatedTabs.push(config);
}
// Create new config, only updating the target window's tabs
const newConfig: TabWindowConfig = {
...currentConfig,
[targetArray]: updatedTabs,
};
tabConfigurationStore.set(newConfig);
Cookies.set('tabConfiguration', JSON.stringify(newConfig));
};
// Helper function to reset tab configuration
export const resetTabConfiguration = () => {
const defaultConfig: TabWindowConfig = {
userTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab) => tab.window === 'developer'),
};
tabConfigurationStore.set(defaultConfig);
Cookies.set('tabConfiguration', JSON.stringify(defaultConfig));
};

View File

@ -6,6 +6,8 @@ import { themeStore } from './lib/stores/theme';
import { stripIndents } from './utils/stripIndent';
import { createHead } from 'remix-island';
import { useEffect } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
import globalStyles from './styles/index.scss?url';
@ -70,11 +72,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
}, [theme]);
return (
<>
<DndProvider backend={HTML5Backend}>
{children}
<ScrollRestoration />
<Scripts />
</>
</DndProvider>
);
}

17
app/utils/localStorage.ts Normal file
View File

@ -0,0 +1,17 @@
export function getLocalStorage(key: string) {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (error) {
console.error(`Error reading from localStorage key "${key}":`, error);
return null;
}
}
export function setLocalStorage(key: string, value: any) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(`Error writing to localStorage key "${key}":`, error);
}
}

View File

@ -92,6 +92,8 @@
"nanostores": "^0.10.3",
"ollama-ai-provider": "^0.15.2",
"react": "^18.3.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-icons": "^5.4.0",

83
pnpm-lock.yaml generated
View File

@ -197,6 +197,12 @@ importers:
react:
specifier: ^18.3.1
version: 18.3.1
react-dnd:
specifier: ^16.0.1
version: 16.0.1(@types/node@22.10.1)(@types/react@18.3.12)(react@18.3.1)
react-dnd-html5-backend:
specifier: ^16.0.1
version: 16.0.1
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
@ -2049,6 +2055,15 @@ packages:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-dnd/asap@5.0.2':
resolution: {integrity: sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==}
'@react-dnd/invariant@4.0.2':
resolution: {integrity: sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==}
'@react-dnd/shallowequal@4.0.2':
resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==}
'@react-stately/utils@3.10.5':
resolution: {integrity: sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==}
peerDependencies:
@ -3309,6 +3324,9 @@ packages:
diffie-hellman@5.0.3:
resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==}
dnd-core@16.0.1:
resolution: {integrity: sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==}
domain-browser@4.22.0:
resolution: {integrity: sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==}
engines: {node: '>=10'}
@ -3846,6 +3864,9 @@ packages:
hmac-drbg@1.0.1:
resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
hosted-git-info@6.1.3:
resolution: {integrity: sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@ -5028,6 +5049,24 @@ packages:
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
engines: {node: '>= 0.8'}
react-dnd-html5-backend@16.0.1:
resolution: {integrity: sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==}
react-dnd@16.0.1:
resolution: {integrity: sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==}
peerDependencies:
'@types/hoist-non-react-statics': '>= 3.3.1'
'@types/node': '>= 12'
'@types/react': '>= 16'
react: '>= 16.14'
peerDependenciesMeta:
'@types/hoist-non-react-statics':
optional: true
'@types/node':
optional: true
'@types/react':
optional: true
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@ -5044,6 +5083,9 @@ packages:
peerDependencies:
react: '*'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-markdown@9.0.1:
resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==}
peerDependencies:
@ -5128,6 +5170,9 @@ packages:
resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==}
engines: {node: '>= 14.16.0'}
redux@4.2.1:
resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==}
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
@ -8116,6 +8161,12 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@react-dnd/asap@5.0.2': {}
'@react-dnd/invariant@4.0.2': {}
'@react-dnd/shallowequal@4.0.2': {}
'@react-stately/utils@3.10.5(react@18.3.1)':
dependencies:
'@swc/helpers': 0.5.15
@ -9678,6 +9729,12 @@ snapshots:
miller-rabin: 4.0.1
randombytes: 2.1.0
dnd-core@16.0.1:
dependencies:
'@react-dnd/asap': 5.0.2
'@react-dnd/invariant': 4.0.2
redux: 4.2.1
domain-browser@4.22.0: {}
dotenv@16.4.7: {}
@ -10423,6 +10480,10 @@ snapshots:
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
hosted-git-info@6.1.3:
dependencies:
lru-cache: 7.18.3
@ -11969,6 +12030,22 @@ snapshots:
iconv-lite: 0.4.24
unpipe: 1.0.0
react-dnd-html5-backend@16.0.1:
dependencies:
dnd-core: 16.0.1
react-dnd@16.0.1(@types/node@22.10.1)(@types/react@18.3.12)(react@18.3.1):
dependencies:
'@react-dnd/invariant': 4.0.2
'@react-dnd/shallowequal': 4.0.2
dnd-core: 16.0.1
fast-deep-equal: 3.1.3
hoist-non-react-statics: 3.3.2
react: 18.3.1
optionalDependencies:
'@types/node': 22.10.1
'@types/react': 18.3.12
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@ -11984,6 +12061,8 @@ snapshots:
dependencies:
react: 18.3.1
react-is@16.13.1: {}
react-markdown@9.0.1(@types/react@18.3.12)(react@18.3.1):
dependencies:
'@types/hast': 3.0.4
@ -12080,6 +12159,10 @@ snapshots:
readdirp@4.0.2: {}
redux@4.2.1:
dependencies:
'@babel/runtime': 7.26.0
regenerator-runtime@0.14.1: {}
regex-recursion@4.3.0: