ui refactor
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
378
app/components/settings/developer/DeveloperWindow.tsx
Normal file
378
app/components/settings/developer/DeveloperWindow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
315
app/components/settings/developer/TabManagement.tsx
Normal file
315
app/components/settings/developer/TabManagement.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
143
app/components/settings/notifications/NotificationsTab.tsx
Normal file
143
app/components/settings/notifications/NotificationsTab.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
215
app/components/settings/settings/SettingsTab.tsx
Normal file
215
app/components/settings/settings/SettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
app/components/settings/shared/DraggableTabList.tsx
Normal file
158
app/components/settings/shared/DraggableTabList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
230
app/components/settings/shared/TabTile.tsx
Normal file
230
app/components/settings/shared/TabTile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
217
app/components/settings/update/UpdateTab.tsx
Normal file
217
app/components/settings/update/UpdateTab.tsx
Normal 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;
|
||||
25
app/components/settings/user/ProfileHeader.tsx
Normal file
25
app/components/settings/user/ProfileHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
385
app/components/settings/user/UsersWindow.tsx
Normal file
385
app/components/settings/user/UsersWindow.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user