mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
feat: bolt dyi datatab (#1570)
* Update DataTab.tsx ## API Key Import Fix We identified and fixed an issue with the API key import functionality in the DataTab component. The problem was that API keys were being stored in localStorage instead of cookies, and the key format was being incorrectly processed. ### Changes Made: 1. **Updated `handleImportAPIKeys` function**: - Changed to store API keys in cookies instead of localStorage - Modified to use provider names directly as keys (e.g., "OpenAI", "Google") - Added logic to skip comment fields (keys starting with "_") - Added page reload after successful import to apply changes immediately 2. **Updated `handleDownloadTemplate` function**: - Changed template format to use provider names as keys - Added explanatory comment in the template - Removed URL-related keys that weren't being used properly 3. **Fixed template format**: - Template now uses the correct format with provider names as keys - Added support for all available providers including Hyperbolic These changes ensure that when users download the template, fill it with their API keys, and import it back, the keys are properly stored in cookies with the correct format that the application expects. * backwards compatible old import template * Update the export / import settings Settings Export/Import Improvements We've completely redesigned the settings export and import functionality to ensure all application settings are properly backed up and restored: Key Improvements Comprehensive Export Format: Now captures ALL settings from both localStorage and cookies, organized into logical categories (core, providers, features, UI, connections, debug, updates) Robust Import System: Automatically detects format version and handles both new and legacy formats with detailed error handling Complete Settings Coverage: Properly exports and imports settings from ALL tabs including: Local provider configurations (Ollama, LMStudio, etc.) Cloud provider API keys (OpenAI, Anthropic, etc.) Feature toggles and preferences UI configurations and tab settings Connection settings (GitHub, Netlify) Debug configurations and logs Technical Details Added version tracking to export files for better compatibility Implemented fallback mechanisms if primary import methods fail Added detailed logging for troubleshooting import/export issues Created helper functions for safer data handling Maintained backward compatibility with older export formats Feature Settings: Feature flags and viewed features Developer mode settings Energy saver mode configurations User Preferences: User profile information Theme settings Tab configurations Connection Settings: Netlify connections Git authentication credentials Any other service connections Debug and System Settings: Debug flags and acknowledged issues Error logs and event logs Update settings and preferences * Update DataTab.tsx * Update GithubConnection.tsx revert the code back as asked * feat: enhance style to match the project * feat:small improvements * feat: add major improvements * Update Dialog.tsx * Delete DataTab.tsx.bak * feat: small updates * Update DataVisualization.tsx * feat: dark mode fix
This commit is contained in:
@@ -29,7 +29,7 @@ import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
|
||||
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
|
||||
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
|
||||
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
|
||||
import DataTab from '~/components/@settings/tabs/data/DataTab';
|
||||
import { DataTab } from '~/components/@settings/tabs/data/DataTab';
|
||||
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
|
||||
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
|
||||
import UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
|
||||
@@ -416,7 +416,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
384
app/components/@settings/tabs/data/DataVisualization.tsx
Normal file
384
app/components/@settings/tabs/data/DataVisualization.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
} from 'chart.js';
|
||||
import { Bar, Pie } from 'react-chartjs-2';
|
||||
import type { Chat } from '~/lib/persistence/chats';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
// Register ChartJS components
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement);
|
||||
|
||||
type DataVisualizationProps = {
|
||||
chats: Chat[];
|
||||
};
|
||||
|
||||
export function DataVisualization({ chats }: DataVisualizationProps) {
|
||||
const [chatsByDate, setChatsByDate] = useState<Record<string, number>>({});
|
||||
const [messagesByRole, setMessagesByRole] = useState<Record<string, number>>({});
|
||||
const [apiKeyUsage, setApiKeyUsage] = useState<Array<{ provider: string; count: number }>>([]);
|
||||
const [averageMessagesPerChat, setAverageMessagesPerChat] = useState<number>(0);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
setIsDarkMode(isDark);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDarkMode(document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chats || chats.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process chat data
|
||||
const chatDates: Record<string, number> = {};
|
||||
const roleCounts: Record<string, number> = {};
|
||||
const apiUsage: Record<string, number> = {};
|
||||
let totalMessages = 0;
|
||||
|
||||
chats.forEach((chat) => {
|
||||
const date = new Date(chat.timestamp).toLocaleDateString();
|
||||
chatDates[date] = (chatDates[date] || 0) + 1;
|
||||
|
||||
chat.messages.forEach((message) => {
|
||||
roleCounts[message.role] = (roleCounts[message.role] || 0) + 1;
|
||||
totalMessages++;
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
const providerMatch = message.content.match(/provider:\s*([\w-]+)/i);
|
||||
const provider = providerMatch ? providerMatch[1] : 'unknown';
|
||||
apiUsage[provider] = (apiUsage[provider] || 0) + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sortedDates = Object.keys(chatDates).sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
|
||||
const sortedChatsByDate: Record<string, number> = {};
|
||||
sortedDates.forEach((date) => {
|
||||
sortedChatsByDate[date] = chatDates[date];
|
||||
});
|
||||
|
||||
setChatsByDate(sortedChatsByDate);
|
||||
setMessagesByRole(roleCounts);
|
||||
setApiKeyUsage(Object.entries(apiUsage).map(([provider, count]) => ({ provider, count })));
|
||||
setAverageMessagesPerChat(totalMessages / chats.length);
|
||||
}, [chats]);
|
||||
|
||||
// Get theme colors from CSS variables to ensure theme consistency
|
||||
const getThemeColor = (varName: string): string => {
|
||||
// Get the CSS variable value from document root
|
||||
if (typeof document !== 'undefined') {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
}
|
||||
|
||||
// Fallback for SSR
|
||||
return isDarkMode ? '#FFFFFF' : '#000000';
|
||||
};
|
||||
|
||||
// Theme-aware chart colors with enhanced dark mode visibility using CSS variables
|
||||
const chartColors = {
|
||||
grid: isDarkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)',
|
||||
text: getThemeColor('--bolt-elements-textPrimary'),
|
||||
textSecondary: getThemeColor('--bolt-elements-textSecondary'),
|
||||
background: getThemeColor('--bolt-elements-bg-depth-1'),
|
||||
accent: getThemeColor('--bolt-elements-button-primary-text'),
|
||||
border: getThemeColor('--bolt-elements-borderColor'),
|
||||
};
|
||||
|
||||
const getChartColors = (index: number) => {
|
||||
// Define color palettes based on Bolt design tokens
|
||||
const baseColors = [
|
||||
// Indigo
|
||||
{
|
||||
base: getThemeColor('--bolt-elements-button-primary-text'),
|
||||
},
|
||||
|
||||
// Pink
|
||||
{
|
||||
base: isDarkMode ? 'rgb(244, 114, 182)' : 'rgb(236, 72, 153)',
|
||||
},
|
||||
|
||||
// Green
|
||||
{
|
||||
base: getThemeColor('--bolt-elements-icon-success'),
|
||||
},
|
||||
|
||||
// Yellow
|
||||
{
|
||||
base: isDarkMode ? 'rgb(250, 204, 21)' : 'rgb(234, 179, 8)',
|
||||
},
|
||||
|
||||
// Blue
|
||||
{
|
||||
base: isDarkMode ? 'rgb(56, 189, 248)' : 'rgb(14, 165, 233)',
|
||||
},
|
||||
];
|
||||
|
||||
// Get the base color for this index
|
||||
const color = baseColors[index % baseColors.length].base;
|
||||
|
||||
// Parse color and generate variations with appropriate opacity
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
|
||||
// Handle rgb/rgba format
|
||||
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)/);
|
||||
|
||||
if (rgbMatch) {
|
||||
[, r, g, b] = rgbMatch.map(Number);
|
||||
} else if (rgbaMatch) {
|
||||
[, r, g, b] = rgbaMatch.map(Number);
|
||||
} else if (color.startsWith('#')) {
|
||||
// Handle hex format
|
||||
const hex = color.slice(1);
|
||||
const bigint = parseInt(hex, 16);
|
||||
r = (bigint >> 16) & 255;
|
||||
g = (bigint >> 8) & 255;
|
||||
b = bigint & 255;
|
||||
}
|
||||
|
||||
return {
|
||||
bg: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.7 : 0.5})`,
|
||||
border: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.9 : 0.8})`,
|
||||
};
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
history: {
|
||||
labels: Object.keys(chatsByDate),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Chats Created',
|
||||
data: Object.values(chatsByDate),
|
||||
backgroundColor: getChartColors(0).bg,
|
||||
borderColor: getChartColors(0).border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
roles: {
|
||||
labels: Object.keys(messagesByRole),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Messages by Role',
|
||||
data: Object.values(messagesByRole),
|
||||
backgroundColor: Object.keys(messagesByRole).map((_, i) => getChartColors(i).bg),
|
||||
borderColor: Object.keys(messagesByRole).map((_, i) => getChartColors(i).border),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
apiUsage: {
|
||||
labels: apiKeyUsage.map((item) => item.provider),
|
||||
datasets: [
|
||||
{
|
||||
label: 'API Usage',
|
||||
data: apiKeyUsage.map((item) => item.count),
|
||||
backgroundColor: apiKeyUsage.map((_, i) => getChartColors(i).bg),
|
||||
borderColor: apiKeyUsage.map((_, i) => getChartColors(i).border),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const baseChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
color: chartColors.text,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 'bold' as const,
|
||||
size: 12,
|
||||
},
|
||||
padding: 16,
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold' as const,
|
||||
},
|
||||
padding: 16,
|
||||
},
|
||||
tooltip: {
|
||||
titleColor: chartColors.text,
|
||||
bodyColor: chartColors.text,
|
||||
backgroundColor: isDarkMode
|
||||
? 'rgba(23, 23, 23, 0.8)' // Dark bg using Tailwind gray-900
|
||||
: 'rgba(255, 255, 255, 0.8)', // Light bg
|
||||
borderColor: chartColors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
...baseChartOptions,
|
||||
plugins: {
|
||||
...baseChartOptions.plugins,
|
||||
title: {
|
||||
...baseChartOptions.plugins.title,
|
||||
text: 'Chat History',
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.grid,
|
||||
drawBorder: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: chartColors.grid,
|
||||
drawBorder: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pieOptions = {
|
||||
...baseChartOptions,
|
||||
plugins: {
|
||||
...baseChartOptions.plugins,
|
||||
title: {
|
||||
...baseChartOptions.plugins.title,
|
||||
text: 'Message Distribution',
|
||||
},
|
||||
legend: {
|
||||
...baseChartOptions.plugins.legend,
|
||||
position: 'right' as const,
|
||||
},
|
||||
datalabels: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 'bold' as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (chats.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="i-ph-chart-line-duotone w-12 h-12 mx-auto mb-4 text-bolt-elements-textTertiary opacity-80" />
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Data Available</h3>
|
||||
<p className="text-bolt-elements-textSecondary">
|
||||
Start creating chats to see your usage statistics and data visualization.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cardClasses = classNames(
|
||||
'p-6 rounded-lg shadow-sm',
|
||||
'bg-bolt-elements-bg-depth-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
);
|
||||
|
||||
const statClasses = classNames('text-3xl font-bold text-bolt-elements-textPrimary', 'flex items-center gap-3');
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Total Chats</h3>
|
||||
<div className={statClasses}>
|
||||
<div className="i-ph-chats-duotone w-8 h-8 text-indigo-500 dark:text-indigo-400" />
|
||||
<span>{chats.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Total Messages</h3>
|
||||
<div className={statClasses}>
|
||||
<div className="i-ph-chat-text-duotone w-8 h-8 text-pink-500 dark:text-pink-400" />
|
||||
<span>{Object.values(messagesByRole).reduce((sum, count) => sum + count, 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Avg. Messages/Chat</h3>
|
||||
<div className={statClasses}>
|
||||
<div className="i-ph-chart-bar-duotone w-8 h-8 text-green-500 dark:text-green-400" />
|
||||
<span>{averageMessagesPerChat.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-6">Chat History</h3>
|
||||
<div className="h-64">
|
||||
<Bar data={chartData.history} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-6">Message Distribution</h3>
|
||||
<div className="h-64">
|
||||
<Pie data={chartData.roles} options={pieOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiKeyUsage.length > 0 && (
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-6">API Usage by Provider</h3>
|
||||
<div className="h-64">
|
||||
<Pie data={chartData.apiUsage} options={pieOptions} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -342,24 +342,86 @@ export default function DebugTab() {
|
||||
try {
|
||||
setLoading((prev) => ({ ...prev, systemInfo: true }));
|
||||
|
||||
// Get browser info
|
||||
const ua = navigator.userAgent;
|
||||
const browserName = ua.includes('Firefox')
|
||||
? 'Firefox'
|
||||
: ua.includes('Chrome')
|
||||
? 'Chrome'
|
||||
: ua.includes('Safari')
|
||||
? 'Safari'
|
||||
: ua.includes('Edge')
|
||||
? 'Edge'
|
||||
: 'Unknown';
|
||||
const browserVersion = ua.match(/(Firefox|Chrome|Safari|Edge)\/([0-9.]+)/)?.[2] || 'Unknown';
|
||||
// Get better OS detection
|
||||
const userAgent = navigator.userAgent;
|
||||
let detectedOS = 'Unknown';
|
||||
let detectedArch = 'unknown';
|
||||
|
||||
// Improved OS detection
|
||||
if (userAgent.indexOf('Win') !== -1) {
|
||||
detectedOS = 'Windows';
|
||||
} else if (userAgent.indexOf('Mac') !== -1) {
|
||||
detectedOS = 'macOS';
|
||||
} else if (userAgent.indexOf('Linux') !== -1) {
|
||||
detectedOS = 'Linux';
|
||||
} else if (userAgent.indexOf('Android') !== -1) {
|
||||
detectedOS = 'Android';
|
||||
} else if (/iPhone|iPad|iPod/.test(userAgent)) {
|
||||
detectedOS = 'iOS';
|
||||
}
|
||||
|
||||
// Better architecture detection
|
||||
if (userAgent.indexOf('x86_64') !== -1 || userAgent.indexOf('x64') !== -1 || userAgent.indexOf('WOW64') !== -1) {
|
||||
detectedArch = 'x64';
|
||||
} else if (userAgent.indexOf('x86') !== -1 || userAgent.indexOf('i686') !== -1) {
|
||||
detectedArch = 'x86';
|
||||
} else if (userAgent.indexOf('arm64') !== -1 || userAgent.indexOf('aarch64') !== -1) {
|
||||
detectedArch = 'arm64';
|
||||
} else if (userAgent.indexOf('arm') !== -1) {
|
||||
detectedArch = 'arm';
|
||||
}
|
||||
|
||||
// Get browser info with improved detection
|
||||
const browserName = (() => {
|
||||
if (userAgent.indexOf('Edge') !== -1 || userAgent.indexOf('Edg/') !== -1) {
|
||||
return 'Edge';
|
||||
}
|
||||
|
||||
if (userAgent.indexOf('Chrome') !== -1) {
|
||||
return 'Chrome';
|
||||
}
|
||||
|
||||
if (userAgent.indexOf('Firefox') !== -1) {
|
||||
return 'Firefox';
|
||||
}
|
||||
|
||||
if (userAgent.indexOf('Safari') !== -1) {
|
||||
return 'Safari';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
})();
|
||||
|
||||
const browserVersionMatch = userAgent.match(/(Edge|Edg|Chrome|Firefox|Safari)[\s/](\d+(\.\d+)*)/);
|
||||
const browserVersion = browserVersionMatch ? browserVersionMatch[2] : 'Unknown';
|
||||
|
||||
// Get performance metrics
|
||||
const memory = (performance as any).memory || {};
|
||||
const timing = performance.timing;
|
||||
const navigation = performance.navigation;
|
||||
const connection = (navigator as any).connection;
|
||||
const connection = (navigator as any).connection || {};
|
||||
|
||||
// Try to use Navigation Timing API Level 2 when available
|
||||
let loadTime = 0;
|
||||
let domReadyTime = 0;
|
||||
|
||||
try {
|
||||
const navEntries = performance.getEntriesByType('navigation');
|
||||
|
||||
if (navEntries.length > 0) {
|
||||
const navTiming = navEntries[0] as PerformanceNavigationTiming;
|
||||
loadTime = navTiming.loadEventEnd - navTiming.startTime;
|
||||
domReadyTime = navTiming.domContentLoadedEventEnd - navTiming.startTime;
|
||||
} else {
|
||||
// Fall back to older API
|
||||
loadTime = timing.loadEventEnd - timing.navigationStart;
|
||||
domReadyTime = timing.domContentLoadedEventEnd - timing.navigationStart;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to older API if Navigation Timing API Level 2 is not available
|
||||
loadTime = timing.loadEventEnd - timing.navigationStart;
|
||||
domReadyTime = timing.domContentLoadedEventEnd - timing.navigationStart;
|
||||
}
|
||||
|
||||
// Get battery info
|
||||
let batteryInfo;
|
||||
@@ -405,9 +467,9 @@ export default function DebugTab() {
|
||||
const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0;
|
||||
|
||||
const systemInfo: SystemInfo = {
|
||||
os: navigator.platform,
|
||||
arch: navigator.userAgent.includes('x64') ? 'x64' : navigator.userAgent.includes('arm') ? 'arm' : 'unknown',
|
||||
platform: navigator.platform,
|
||||
os: detectedOS,
|
||||
arch: detectedArch,
|
||||
platform: navigator.platform || 'unknown',
|
||||
cpus: navigator.hardwareConcurrency + ' cores',
|
||||
memory: {
|
||||
total: formatBytes(totalMemory),
|
||||
@@ -423,7 +485,7 @@ export default function DebugTab() {
|
||||
userAgent: navigator.userAgent,
|
||||
cookiesEnabled: navigator.cookieEnabled,
|
||||
online: navigator.onLine,
|
||||
platform: navigator.platform,
|
||||
platform: navigator.platform || 'unknown',
|
||||
cores: navigator.hardwareConcurrency,
|
||||
},
|
||||
screen: {
|
||||
@@ -445,8 +507,8 @@ export default function DebugTab() {
|
||||
usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0,
|
||||
},
|
||||
timing: {
|
||||
loadTime: timing.loadEventEnd - timing.navigationStart,
|
||||
domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart,
|
||||
loadTime,
|
||||
domReadyTime,
|
||||
readyStart: timing.fetchStart - timing.navigationStart,
|
||||
redirectTime: timing.redirectEnd - timing.redirectStart,
|
||||
appcacheTime: timing.domainLookupStart - timing.fetchStart,
|
||||
@@ -483,6 +545,23 @@ export default function DebugTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format bytes to human readable format with better precision
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
|
||||
// Return with proper precision based on unit size
|
||||
if (i === 0) {
|
||||
return `${bytes} ${units[i]}`;
|
||||
}
|
||||
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
|
||||
};
|
||||
|
||||
const getWebAppInfo = async () => {
|
||||
try {
|
||||
setLoading((prev) => ({ ...prev, webAppInfo: true }));
|
||||
@@ -520,20 +599,6 @@ export default function DebugTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format bytes to human readable format
|
||||
const formatBytes = (bytes: number) => {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${Math.round(size)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const handleLogPerformance = () => {
|
||||
try {
|
||||
setLoading((prev) => ({ ...prev, performance: true }));
|
||||
@@ -1353,9 +1418,7 @@ export default function DebugTab() {
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:code w-3.5 h-3.5 text-purple-500" />
|
||||
DOM Ready: {systemInfo
|
||||
? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)
|
||||
: '-'}s
|
||||
DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
25
app/components/ui/Checkbox.tsx
Normal file
25
app/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:focus:ring-purple-400 dark:focus:ring-offset-gray-900',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Check className="h-3 w-3 text-purple-500 dark:text-purple-400" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
export { Checkbox };
|
||||
@@ -1,9 +1,13 @@
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { motion, type Variants } from 'framer-motion';
|
||||
import React, { memo, type ReactNode } from 'react';
|
||||
import React, { memo, type ReactNode, useState, useEffect } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { IconButton } from './IconButton';
|
||||
import { Button } from './Button';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import { Label } from './Label';
|
||||
|
||||
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
|
||||
|
||||
@@ -17,12 +21,14 @@ interface DialogButtonProps {
|
||||
export const DialogButton = memo(({ type, children, onClick, disabled }: DialogButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
className={classNames('inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors', {
|
||||
'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600': type === 'primary',
|
||||
'bg-transparent text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100':
|
||||
type === 'secondary',
|
||||
'bg-transparent text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10': type === 'danger',
|
||||
})}
|
||||
className={classNames(
|
||||
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors',
|
||||
type === 'primary'
|
||||
? 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600'
|
||||
: type === 'secondary'
|
||||
? 'bg-transparent text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
: 'bg-transparent text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10',
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -34,7 +40,7 @@ export const DialogButton = memo(({ type, children, onClick, disabled }: DialogB
|
||||
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
|
||||
return (
|
||||
<RadixDialog.Title
|
||||
className={classNames('text-lg font-medium text-bolt-elements-textPrimary', 'flex items-center gap-2', className)}
|
||||
className={classNames('text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -45,7 +51,7 @@ export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.
|
||||
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
|
||||
return (
|
||||
<RadixDialog.Description
|
||||
className={classNames('text-sm text-bolt-elements-textSecondary', 'mt-1', className)}
|
||||
className={classNames('text-sm text-bolt-elements-textSecondary mt-1', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -99,11 +105,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'fixed inset-0 z-[9999]',
|
||||
'bg-[#FAFAFA]/80 dark:bg-[#0A0A0A]/80',
|
||||
'backdrop-blur-[2px]',
|
||||
)}
|
||||
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
@@ -114,11 +116,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
|
||||
<RadixDialog.Content asChild>
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'rounded-lg shadow-lg',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'z-[9999] w-[520px]',
|
||||
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-bolt-elements-borderColor z-[9999] w-[520px]',
|
||||
className,
|
||||
)}
|
||||
initial="closed"
|
||||
@@ -132,7 +130,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
|
||||
<RadixDialog.Close asChild onClick={onClose}>
|
||||
<IconButton
|
||||
icon="i-ph:x"
|
||||
className="absolute top-3 right-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||
className="absolute top-3 right-3 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary"
|
||||
/>
|
||||
</RadixDialog.Close>
|
||||
)}
|
||||
@@ -142,3 +140,310 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
|
||||
</RadixDialog.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the ConfirmationDialog component
|
||||
*/
|
||||
export interface ConfirmationDialogProps {
|
||||
/**
|
||||
* Whether the dialog is open
|
||||
*/
|
||||
isOpen: boolean;
|
||||
|
||||
/**
|
||||
* Callback when the dialog is closed
|
||||
*/
|
||||
onClose: () => void;
|
||||
|
||||
/**
|
||||
* Callback when the confirm button is clicked
|
||||
*/
|
||||
onConfirm: () => void;
|
||||
|
||||
/**
|
||||
* The title of the dialog
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The description of the dialog
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* The text for the confirm button
|
||||
*/
|
||||
confirmLabel?: string;
|
||||
|
||||
/**
|
||||
* The text for the cancel button
|
||||
*/
|
||||
cancelLabel?: string;
|
||||
|
||||
/**
|
||||
* The variant of the confirm button
|
||||
*/
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
|
||||
/**
|
||||
* Whether the confirm button is in a loading state
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable confirmation dialog component that uses the Dialog component
|
||||
*/
|
||||
export function ConfirmationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'default',
|
||||
isLoading = false,
|
||||
onConfirm,
|
||||
}: ConfirmationDialogProps) {
|
||||
return (
|
||||
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog showCloseButton={false}>
|
||||
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="mb-4">{description}</DialogDescription>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={
|
||||
variant === 'destructive'
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent hover:bg-bolt-elements-button-primary-backgroundHover'
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="i-ph-spinner-gap-bold animate-spin w-4 h-4 mr-2" />
|
||||
{confirmLabel}
|
||||
</>
|
||||
) : (
|
||||
confirmLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for selection item in SelectionDialog
|
||||
*/
|
||||
type SelectionItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for the SelectionDialog component
|
||||
*/
|
||||
export interface SelectionDialogProps {
|
||||
/**
|
||||
* The title of the dialog
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The items to select from
|
||||
*/
|
||||
items: SelectionItem[];
|
||||
|
||||
/**
|
||||
* Whether the dialog is open
|
||||
*/
|
||||
isOpen: boolean;
|
||||
|
||||
/**
|
||||
* Callback when the dialog is closed
|
||||
*/
|
||||
onClose: () => void;
|
||||
|
||||
/**
|
||||
* Callback when the confirm button is clicked with selected item IDs
|
||||
*/
|
||||
onConfirm: (selectedIds: string[]) => void;
|
||||
|
||||
/**
|
||||
* The text for the confirm button
|
||||
*/
|
||||
confirmLabel?: string;
|
||||
|
||||
/**
|
||||
* The maximum height of the selection list
|
||||
*/
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable selection dialog component that uses the Dialog component
|
||||
*/
|
||||
export function SelectionDialog({
|
||||
title,
|
||||
items,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmLabel = 'Confirm',
|
||||
maxHeight = '60vh',
|
||||
}: SelectionDialogProps) {
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
|
||||
// Reset selected items when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedItems([]);
|
||||
setSelectAll(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggleItem = (id: string) => {
|
||||
setSelectedItems((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedItems.length === items.length) {
|
||||
setSelectedItems([]);
|
||||
setSelectAll(false);
|
||||
} else {
|
||||
setSelectedItems(items.map((item) => item.id));
|
||||
setSelectAll(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedItems);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Calculate the height for the virtualized list
|
||||
const listHeight = Math.min(
|
||||
items.length * 60,
|
||||
parseInt(maxHeight.replace('vh', '')) * window.innerHeight * 0.01 - 40,
|
||||
);
|
||||
|
||||
// Render each item in the virtualized list
|
||||
const ItemRenderer = ({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const item = items[index];
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={classNames(
|
||||
'flex items-start space-x-3 p-2 rounded-md transition-colors',
|
||||
selectedItems.includes(item.id)
|
||||
? 'bg-bolt-elements-item-backgroundAccent'
|
||||
: 'bg-bolt-elements-bg-depth-2 hover:bg-bolt-elements-item-backgroundActive',
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id={`item-${item.id}`}
|
||||
checked={selectedItems.includes(item.id)}
|
||||
onCheckedChange={() => handleToggleItem(item.id)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor={`item-${item.id}`}
|
||||
className={classNames(
|
||||
'text-sm font-medium cursor-pointer',
|
||||
selectedItems.includes(item.id)
|
||||
? 'text-bolt-elements-item-contentAccent'
|
||||
: 'text-bolt-elements-textPrimary',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Label>
|
||||
{item.description && <p className="text-xs text-bolt-elements-textSecondary">{item.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog showCloseButton={false}>
|
||||
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="mt-2 mb-4">
|
||||
Select the items you want to include and click{' '}
|
||||
<span className="text-bolt-elements-item-contentAccent font-medium">{confirmLabel}</span>.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-bolt-elements-textSecondary">
|
||||
{selectedItems.length} of {items.length} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
className="text-xs h-8 px-2 text-bolt-elements-textPrimary hover:text-bolt-elements-item-contentAccent hover:bg-bolt-elements-item-backgroundAccent bg-bolt-elements-bg-depth-2 dark:bg-transparent"
|
||||
>
|
||||
{selectAll ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pr-2 border rounded-md border-bolt-elements-borderColor bg-bolt-elements-bg-depth-2"
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
{items.length > 0 ? (
|
||||
<FixedSizeList
|
||||
height={listHeight}
|
||||
width="100%"
|
||||
itemCount={items.length}
|
||||
itemSize={60}
|
||||
className="scrollbar-thin scrollbar-thumb-rounded scrollbar-thumb-bolt-elements-bg-depth-3"
|
||||
>
|
||||
{ItemRenderer}
|
||||
</FixedSizeList>
|
||||
) : (
|
||||
<div className="text-center py-4 text-sm text-bolt-elements-textTertiary">No items to display</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedItems.length === 0}
|
||||
className="bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user