diff --git a/.windsurf/config.json b/.windsurf/config.json new file mode 100644 index 00000000..27848d34 --- /dev/null +++ b/.windsurf/config.json @@ -0,0 +1,39 @@ +{ + "enabled": true, + "rulesPath": ".windsurf/rules.json", + "integration": { + "ide": { + "cursor": true, + "vscode": true + }, + "autoApply": true, + "notifications": true, + "autoFix": { + "enabled": true, + "onSave": true, + "formatOnSave": true, + "suggestImports": true, + "suggestComponents": true + }, + "suggestions": { + "inline": true, + "quickFix": true, + "codeActions": true, + "snippets": true + } + }, + "features": { + "codeCompletion": true, + "linting": true, + "formatting": true, + "importValidation": true, + "dependencyChecks": true, + "uiStandardization": true + }, + "hooks": { + "preCommit": true, + "prePush": true, + "onFileCreate": true, + "onImportAdd": true + } +} diff --git a/.windsurf/rules.json b/.windsurf/rules.json new file mode 100644 index 00000000..a0008b1e --- /dev/null +++ b/.windsurf/rules.json @@ -0,0 +1,103 @@ +{ + "version": "1.0", + "rules": { + "fileTypes": { + "typescript": ["ts", "tsx"], + "javascript": ["js", "jsx", "mjs", "cjs"], + "json": ["json"], + "markdown": ["md"], + "css": ["css"], + "dockerfile": ["Dockerfile"] + }, + "formatting": { + "typescript": { + "indentSize": 2, + "useTabs": false, + "maxLineLength": 100, + "semicolons": true, + "quotes": "single", + "trailingComma": "es5" + }, + "javascript": { + "indentSize": 2, + "useTabs": false, + "maxLineLength": 100, + "semicolons": true, + "quotes": "single", + "trailingComma": "es5" + } + }, + "linting": { + "typescript": { + "noUnusedVariables": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noConsole": "warn" + } + }, + "dependencies": { + "nodeVersion": ">=18.18.0", + "packageManager": "pnpm", + "requiredFiles": ["package.json", "tsconfig.json", ".env.example"] + }, + "git": { + "ignoredPaths": ["node_modules", "build", ".env", ".env.local"], + "protectedBranches": ["main", "master"] + }, + "testing": { + "framework": "vitest", + "coverage": { + "statements": 70, + "branches": 70, + "functions": 70, + "lines": 70 + } + }, + "security": { + "secrets": { + "patterns": ["API_KEY", "SECRET", "PASSWORD", "TOKEN"], + "locations": [".env", ".env.local"] + } + }, + "commands": { + "dev": "pnpm dev", + "build": "pnpm build", + "test": "pnpm test", + "lint": "pnpm lint", + "typecheck": "pnpm typecheck" + }, + "codeQuality": { + "imports": { + "validateImports": true, + "checkPackageAvailability": true, + "requireExactVersions": true, + "preventUnusedImports": true + }, + "fileManagement": { + "preventUnnecessaryFiles": true, + "requireFileJustification": true, + "checkExistingImplementations": true + }, + "dependencies": { + "autoInstallMissing": false, + "validateVersionCompatibility": true, + "checkPackageJson": true + } + }, + "uiStandards": { + "styling": { + "framework": "tailwind", + "preferredIconSets": ["@iconify-json/ph", "@iconify-json/svg-spinners"], + "colorScheme": { + "useSystemPreference": true, + "supportDarkMode": true + }, + "components": { + "preferModern": true, + "accessibility": true, + "responsive": true + } + } + } + } +} diff --git a/app/components/settings/Settings.module.scss b/app/components/settings/Settings.module.scss deleted file mode 100644 index 639cbbc5..00000000 --- a/app/components/settings/Settings.module.scss +++ /dev/null @@ -1,63 +0,0 @@ -.settings-tabs { - button { - width: 100%; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1rem; - border-radius: 0.5rem; - text-align: left; - font-size: 0.875rem; - transition: all 0.2s; - margin-bottom: 0.5rem; - - &.active { - background: var(--bolt-elements-button-primary-background); - color: var(--bolt-elements-textPrimary); - } - - &:not(.active) { - background: var(--bolt-elements-bg-depth-3); - color: var(--bolt-elements-textPrimary); - - &:hover { - background: var(--bolt-elements-button-primary-backgroundHover); - } - } - } -} - -.settings-button { - background-color: var(--bolt-elements-button-primary-background); - color: var(--bolt-elements-textPrimary); - border-radius: 0.5rem; - padding: 0.5rem 1rem; - transition: background-color 0.2s; - - &:hover { - background-color: var(--bolt-elements-button-primary-backgroundHover); - } -} - -.settings-danger-area { - background-color: transparent; - color: var(--bolt-elements-textPrimary); - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1rem; - border-style: solid; - border-color: var(--bolt-elements-button-danger-backgroundHover); - border-width: thin; - - button { - background-color: var(--bolt-elements-button-danger-background); - color: var(--bolt-elements-button-danger-text); - border-radius: 0.5rem; - padding: 0.5rem 1rem; - transition: background-color 0.2s; - - &:hover { - background-color: var(--bolt-elements-button-danger-backgroundHover); - } - } -} diff --git a/app/components/settings/SettingsWindow.tsx b/app/components/settings/SettingsWindow.tsx index f53d547c..6d19c99f 100644 --- a/app/components/settings/SettingsWindow.tsx +++ b/app/components/settings/SettingsWindow.tsx @@ -1,10 +1,11 @@ import * as RadixDialog from '@radix-ui/react-dialog'; -import { motion } from 'framer-motion'; -import { useState, type ReactElement } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useState } from 'react'; import { classNames } from '~/utils/classNames'; -import { DialogTitle, dialogVariants, dialogBackdropVariants } from '~/components/ui/Dialog'; -import { IconButton } from '~/components/ui/IconButton'; -import styles from './Settings.module.scss'; +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'; @@ -18,110 +19,281 @@ interface SettingsProps { onClose: () => void; } -type TabType = 'data' | 'providers' | 'features' | 'debug' | 'event-logs' | 'connection'; - export const SettingsWindow = ({ open, onClose }: SettingsProps) => { const { debug, eventLogs } = useSettings(); - const [activeTab, setActiveTab] = useState('data'); + const [searchQuery, setSearchQuery] = useState(''); + const [activeTab, setActiveTab] = useState(null); - const tabs: { id: TabType; label: string; icon: string; component?: ReactElement }[] = [ - { id: 'data', label: 'Data', icon: 'i-ph:database', component: }, - { id: 'providers', label: 'Providers', icon: 'i-ph:key', component: }, - { id: 'connection', label: 'Connection', icon: 'i-ph:link', component: }, - { id: 'features', label: 'Features', icon: 'i-ph:star', component: }, - ...(debug - ? [ - { - id: 'debug' as TabType, - label: 'Debug Tab', - icon: 'i-ph:bug', - component: , - }, - ] - : []), - ...(eventLogs - ? [ - { - id: 'event-logs' as TabType, - label: 'Event Logs', - icon: 'i-ph:list-bullets', - component: , - }, - ] - : []), - ]; + 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: () => , + 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: () => , + 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: () => , + 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: () => , + 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: () => , + 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: () => , + 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: () => , + 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, + ); + + const handleBackToDashboard = () => { + setActiveTab(null); + onClose(); + }; + + const activeTabItem = allSettingItems.find((item) => item.id === activeTab); return ( - - - - - -
-
- - Settings - - {tabs.map((tab) => ( - - ))} -
- - +
+
+ -
-
{tabs.find((tab) => tab.id === activeTab)?.component}
-
-
- - - - - +
|
+ + {activeTabItem && ( +
+
+
+

+ {activeTabItem.label} +

+

{activeTabItem.description}

+
+
+ )} +
+ + +
+
+ {allSettingItems.find((item) => item.id === activeTab)?.component()} +
+ + ) : ( + +
+
+
+ + Bolt Control Panel + +
+
+
+ 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', + )} + /> +
+
+
+
+ +
+
+ +
+
+ {(Object.keys(groupedItems) as SettingCategory[]).map((category) => ( +
+
+
+

+ {categoryLabels[category]} +

+
+
+ {groupedItems[category].map((item) => ( + + ))} +
+
+ ))} +
+
+ + )} + + + +
); diff --git a/app/components/settings/connections/ConnectionsTab.tsx b/app/components/settings/connections/ConnectionsTab.tsx index 4b89022e..31883745 100644 --- a/app/components/settings/connections/ConnectionsTab.tsx +++ b/app/components/settings/connections/ConnectionsTab.tsx @@ -1,150 +1,207 @@ import React, { useState, useEffect } from 'react'; -import { toast } from 'react-toastify'; -import Cookies from 'js-cookie'; import { logStore } from '~/lib/stores/logs'; +import { classNames } from '~/utils/classNames'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; interface GitHubUserResponse { login: string; - id: number; - [key: string]: any; // for other properties we don't explicitly need + avatar_url: string; + html_url: string; +} + +interface GitHubConnection { + user: GitHubUserResponse | null; + token: string; } export default function ConnectionsTab() { - const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || ''); - const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || ''); - const [isConnected, setIsConnected] = useState(false); - const [isVerifying, setIsVerifying] = useState(false); + const [connection, setConnection] = useState({ + user: null, + token: '', + }); + const [isLoading, setIsLoading] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + // Load saved connection on mount useEffect(() => { - // Check if credentials exist and verify them - if (githubUsername && githubToken) { - verifyGitHubCredentials(); + const savedConnection = localStorage.getItem('github_connection'); + + if (savedConnection) { + setConnection(JSON.parse(savedConnection)); } + + setIsLoading(false); }, []); - const verifyGitHubCredentials = async () => { - setIsVerifying(true); - + const fetchGithubUser = async (token: string) => { try { + setIsConnecting(true); + const response = await fetch('https://api.github.com/user', { headers: { - Authorization: `Bearer ${githubToken}`, + Authorization: `Bearer ${token}`, }, }); - if (response.ok) { - const data = (await response.json()) as GitHubUserResponse; - - if (data.login === githubUsername) { - setIsConnected(true); - return true; - } + if (!response.ok) { + throw new Error('Invalid token or unauthorized'); } - setIsConnected(false); + const data = (await response.json()) as GitHubUserResponse; + const newConnection = { user: data, token }; - return false; + // Save connection + localStorage.setItem('github_connection', JSON.stringify(newConnection)); + setConnection(newConnection); + toast.success('Successfully connected to GitHub'); } catch (error) { - console.error('Error verifying GitHub credentials:', error); - setIsConnected(false); - - return false; + logStore.logError('Failed to authenticate with GitHub', { error }); + toast.error('Failed to connect to GitHub'); + setConnection({ user: null, token: '' }); } finally { - setIsVerifying(false); + setIsConnecting(false); } }; - const handleSaveConnection = async () => { - if (!githubUsername || !githubToken) { - toast.error('Please provide both GitHub username and token'); - return; - } - - setIsVerifying(true); - - const isValid = await verifyGitHubCredentials(); - - if (isValid) { - Cookies.set('githubUsername', githubUsername); - Cookies.set('githubToken', githubToken); - logStore.logSystem('GitHub connection settings updated', { - username: githubUsername, - hasToken: !!githubToken, - }); - toast.success('GitHub credentials verified and saved successfully!'); - Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' })); - setIsConnected(true); - } else { - toast.error('Invalid GitHub credentials. Please check your username and token.'); - } + const handleConnect = async (event: React.FormEvent) => { + event.preventDefault(); + await fetchGithubUser(connection.token); }; const handleDisconnect = () => { - Cookies.remove('githubUsername'); - Cookies.remove('githubToken'); - Cookies.remove('git:github.com'); - setGithubUsername(''); - setGithubToken(''); - setIsConnected(false); - logStore.logSystem('GitHub connection removed'); - toast.success('GitHub connection removed successfully!'); + localStorage.removeItem('github_connection'); + setConnection({ user: null, token: '' }); + toast.success('Disconnected from GitHub'); }; - return ( -
-

GitHub Connection

-
-
- - setGithubUsername(e.target.value)} - disabled={isVerifying} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50" - /> -
-
- - setGithubToken(e.target.value)} - disabled={isVerifying} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50" - /> + if (isLoading) { + return ( +
+
+
+ Loading...
-
- {!isConnected ? ( - - ) : ( - - )} - {isConnected && ( - -
- Connected to GitHub - - )} + ); + } + + return ( +
+ {/* Header */} + +
+

Connection Settings

+ +

+ Manage your external service connections and integrations +

+ +
+ {/* GitHub Connection */} + +
+
+
+

GitHub Connection

+
+ +
+
+ + +
+ +
+ + setConnection((prev) => ({ ...prev, token: e.target.value }))} + disabled={isConnecting || !!connection.user} + placeholder="Enter your GitHub token" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#333333]', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-purple-500', + 'disabled:opacity-50', + )} + /> +
+
+ +
+ {!connection.user ? ( + + ) : ( + + )} + + {connection.user && ( + +
+ Connected to GitHub + + )} +
+
+
); diff --git a/app/components/settings/data/DataTab.tsx b/app/components/settings/data/DataTab.tsx index 9219d015..98835070 100644 --- a/app/components/settings/data/DataTab.tsx +++ b/app/components/settings/data/DataTab.tsx @@ -1,388 +1,422 @@ -import React, { useState } from 'react'; -import { useNavigate } from '@remix-run/react'; -import Cookies from 'js-cookie'; +import { useState, useRef } from 'react'; +import { motion } from 'framer-motion'; import { toast } from 'react-toastify'; -import { db, deleteById, getAll, setMessages } from '~/lib/persistence'; -import { logStore } from '~/lib/stores/logs'; -import { classNames } from '~/utils/classNames'; -import type { Message } from 'ai'; - -// List of supported providers that can have API keys -const API_KEY_PROVIDERS = [ - 'Anthropic', - 'OpenAI', - 'Google', - 'Groq', - 'HuggingFace', - 'OpenRouter', - 'Deepseek', - 'Mistral', - 'OpenAILike', - 'Together', - 'xAI', - 'Perplexity', - 'Cohere', - 'AzureOpenAI', - 'AmazonBedrock', -] as const; - -interface ApiKeys { - [key: string]: string; -} +import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog'; +import { db, getAll } from '~/lib/persistence'; export default function DataTab() { - const navigate = useNavigate(); + const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false); + const [isImportingKeys, setIsImportingKeys] = useState(false); + const [isResetting, setIsResetting] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - - const downloadAsJson = (data: any, filename: string) => { - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }; + const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false); + const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false); + const fileInputRef = useRef(null); + const apiKeyFileInputRef = useRef(null); const handleExportAllChats = async () => { - if (!db) { - const error = new Error('Database is not available'); - logStore.logError('Failed to export chats - DB unavailable', error); - toast.error('Database is not available'); - - return; - } - try { + if (!db) { + throw new Error('Database not initialized'); + } + + // Get all chats from IndexedDB const allChats = await getAll(db); const exportData = { chats: allChats, exportDate: new Date().toISOString(), }; - downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`); - logStore.logSystem('Chats exported successfully', { count: allChats.length }); + // Download as JSON + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-chats-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success('Chats exported successfully'); } catch (error) { - logStore.logError('Failed to export chats', error); + console.error('Export error:', error); toast.error('Failed to export chats'); - console.error(error); } }; - const handleDeleteAllChats = async () => { - const confirmDelete = window.confirm('Are you sure you want to delete all chats? This action cannot be undone.'); + const handleExportSettings = () => { + try { + const settings = { + userProfile: localStorage.getItem('bolt_user_profile'), + settings: localStorage.getItem('bolt_settings'), + exportDate: new Date().toISOString(), + }; - if (!confirmDelete) { - return; + const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-settings-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success('Settings exported successfully'); + } catch (error) { + console.error('Export error:', error); + toast.error('Failed to export settings'); } + }; - if (!db) { - const error = new Error('Database is not available'); - logStore.logError('Failed to delete chats - DB unavailable', error); - toast.error('Database is not available'); + const handleImportSettings = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { return; } try { - setIsDeleting(true); + const content = await file.text(); + const settings = JSON.parse(content); - const allChats = await getAll(db); - await Promise.all(allChats.map((chat) => deleteById(db!, chat.id))); - logStore.logSystem('All chats deleted successfully', { count: allChats.length }); - toast.success('All chats deleted successfully'); - navigate('/', { replace: true }); + if (settings.userProfile) { + localStorage.setItem('bolt_user_profile', settings.userProfile); + } + + if (settings.settings) { + localStorage.setItem('bolt_settings', settings.settings); + } + + window.location.reload(); // Reload to apply settings + toast.success('Settings imported successfully'); } catch (error) { - logStore.logError('Failed to delete chats', error); - toast.error('Failed to delete chats'); - console.error(error); + console.error('Import error:', error); + toast.error('Failed to import settings'); + } + }; + + const handleImportAPIKeys = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + setIsImportingKeys(true); + + try { + const content = await file.text(); + const keys = JSON.parse(content); + + // Validate and save each key + Object.entries(keys).forEach(([key, value]) => { + if (typeof value !== 'string') { + throw new Error(`Invalid value for key: ${key}`); + } + + localStorage.setItem(`bolt_${key.toLowerCase()}`, value); + }); + + toast.success('API keys imported successfully'); + } catch (error) { + console.error('Error importing API keys:', error); + toast.error('Failed to import API keys'); + } finally { + setIsImportingKeys(false); + + if (apiKeyFileInputRef.current) { + apiKeyFileInputRef.current.value = ''; + } + } + }; + + const handleDownloadTemplate = () => { + setIsDownloadingTemplate(true); + + try { + const template = { + Anthropic_API_KEY: '', + OpenAI_API_KEY: '', + Google_API_KEY: '', + Groq_API_KEY: '', + HuggingFace_API_KEY: '', + OpenRouter_API_KEY: '', + Deepseek_API_KEY: '', + Mistral_API_KEY: '', + OpenAILike_API_KEY: '', + Together_API_KEY: '', + xAI_API_KEY: '', + Perplexity_API_KEY: '', + Cohere_API_KEY: '', + AzureOpenAI_API_KEY: '', + OPENAI_LIKE_API_BASE_URL: '', + LMSTUDIO_API_BASE_URL: '', + OLLAMA_API_BASE_URL: '', + TOGETHER_API_BASE_URL: '', + }; + + const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'bolt-api-keys-template.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success('Template downloaded successfully'); + } catch (error) { + console.error('Error downloading template:', error); + toast.error('Failed to download template'); + } finally { + setIsDownloadingTemplate(false); + } + }; + + const handleResetSettings = async () => { + setIsResetting(true); + + try { + // Clear all stored settings + localStorage.removeItem('bolt_user_profile'); + localStorage.removeItem('bolt_settings'); + localStorage.removeItem('bolt_chat_history'); + + // Reload the page to apply reset + window.location.reload(); + toast.success('Settings reset successfully'); + } catch (error) { + console.error('Reset error:', error); + toast.error('Failed to reset settings'); + } finally { + setIsResetting(false); + } + }; + + const handleDeleteAllChats = async () => { + setIsDeleting(true); + + try { + // Clear chat history + localStorage.removeItem('bolt_chat_history'); + toast.success('Chat history deleted successfully'); + } catch (error) { + console.error('Delete error:', error); + toast.error('Failed to delete chat history'); } finally { setIsDeleting(false); } }; - const handleExportSettings = () => { - const settings = { - providers: Cookies.get('providers'), - isDebugEnabled: Cookies.get('isDebugEnabled'), - isEventLogsEnabled: Cookies.get('isEventLogsEnabled'), - isLocalModelsEnabled: Cookies.get('isLocalModelsEnabled'), - promptId: Cookies.get('promptId'), - isLatestBranch: Cookies.get('isLatestBranch'), - commitHash: Cookies.get('commitHash'), - eventLogs: Cookies.get('eventLogs'), - selectedModel: Cookies.get('selectedModel'), - selectedProvider: Cookies.get('selectedProvider'), - githubUsername: Cookies.get('githubUsername'), - githubToken: Cookies.get('githubToken'), - bolt_theme: localStorage.getItem('bolt_theme'), - }; - - downloadAsJson(settings, 'bolt-settings.json'); - toast.success('Settings exported successfully'); - }; - - const handleImportSettings = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - - if (!file) { - return; - } - - const reader = new FileReader(); - - reader.onload = (e) => { - try { - const settings = JSON.parse(e.target?.result as string); - - Object.entries(settings).forEach(([key, value]) => { - if (key === 'bolt_theme') { - if (value) { - localStorage.setItem(key, value as string); - } - } else if (value) { - Cookies.set(key, value as string); - } - }); - - toast.success('Settings imported successfully. Please refresh the page for changes to take effect.'); - } catch (error) { - toast.error('Failed to import settings. Make sure the file is a valid JSON file.'); - console.error('Failed to import settings:', error); - } - }; - reader.readAsText(file); - event.target.value = ''; - }; - - const handleExportApiKeyTemplate = () => { - const template: ApiKeys = {}; - API_KEY_PROVIDERS.forEach((provider) => { - template[`${provider}_API_KEY`] = ''; - }); - - template.OPENAI_LIKE_API_BASE_URL = ''; - template.LMSTUDIO_API_BASE_URL = ''; - template.OLLAMA_API_BASE_URL = ''; - template.TOGETHER_API_BASE_URL = ''; - - downloadAsJson(template, 'api-keys-template.json'); - toast.success('API keys template exported successfully'); - }; - - const handleImportApiKeys = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - - if (!file) { - return; - } - - const reader = new FileReader(); - - reader.onload = (e) => { - try { - const apiKeys = JSON.parse(e.target?.result as string); - let importedCount = 0; - const consolidatedKeys: Record = {}; - - API_KEY_PROVIDERS.forEach((provider) => { - const keyName = `${provider}_API_KEY`; - - if (apiKeys[keyName]) { - consolidatedKeys[provider] = apiKeys[keyName]; - importedCount++; - } - }); - - if (importedCount > 0) { - // Store all API keys in a single cookie as JSON - Cookies.set('apiKeys', JSON.stringify(consolidatedKeys)); - - // Also set individual cookies for backward compatibility - Object.entries(consolidatedKeys).forEach(([provider, key]) => { - Cookies.set(`${provider}_API_KEY`, key); - }); - - toast.success(`Successfully imported ${importedCount} API keys/URLs. Refreshing page to apply changes...`); - - // Reload the page after a short delay to allow the toast to be seen - setTimeout(() => { - window.location.reload(); - }, 1500); - } else { - toast.warn('No valid API keys found in the file'); - } - - // Set base URLs if they exist - ['OPENAI_LIKE_API_BASE_URL', 'LMSTUDIO_API_BASE_URL', 'OLLAMA_API_BASE_URL', 'TOGETHER_API_BASE_URL'].forEach( - (baseUrl) => { - if (apiKeys[baseUrl]) { - Cookies.set(baseUrl, apiKeys[baseUrl]); - } - }, - ); - } catch (error) { - toast.error('Failed to import API keys. Make sure the file is a valid JSON file.'); - console.error('Failed to import API keys:', error); - } - }; - reader.readAsText(file); - event.target.value = ''; - }; - - const processChatData = ( - data: any, - ): Array<{ - id: string; - messages: Message[]; - description: string; - urlId?: string; - }> => { - // Handle Bolt standard format (single chat) - if (data.messages && Array.isArray(data.messages)) { - const chatId = crypto.randomUUID(); - return [ - { - id: chatId, - messages: data.messages, - description: data.description || 'Imported Chat', - urlId: chatId, - }, - ]; - } - - // Handle Bolt export format (multiple chats) - if (data.chats && Array.isArray(data.chats)) { - return data.chats.map((chat: { id?: string; messages: Message[]; description?: string; urlId?: string }) => ({ - id: chat.id || crypto.randomUUID(), - messages: chat.messages, - description: chat.description || 'Imported Chat', - urlId: chat.urlId, - })); - } - - console.error('No matching format found for:', data); - throw new Error('Unsupported chat format'); - }; - - const handleImportChats = () => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json'; - - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - - if (!file || !db) { - toast.error('Something went wrong'); - return; - } - - try { - const content = await file.text(); - const data = JSON.parse(content); - const chatsToImport = processChatData(data); - - for (const chat of chatsToImport) { - await setMessages(db, chat.id, chat.messages, chat.urlId, chat.description); - } - - logStore.logSystem('Chats imported successfully', { count: chatsToImport.length }); - toast.success(`Successfully imported ${chatsToImport.length} chat${chatsToImport.length > 1 ? 's' : ''}`); - window.location.reload(); - } catch (error) { - if (error instanceof Error) { - logStore.logError('Failed to import chats:', error); - toast.error('Failed to import chats: ' + error.message); - } else { - toast.error('Failed to import chats'); - } - - console.error(error); - } - }; - - input.click(); - }; - return ( -
-
-

Data Management

-
-
-
-

Chat History

-

Export or delete all your chat history.

-
- - - -
+
+ + {/* Reset Settings Dialog */} + + +
+
+
+ Reset All Settings?
- -
-

Settings Backup

-

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

-
- - -
-
- -
-

API Keys Management

-

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

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

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

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

Chat History

-
+

Export or delete all your chat history.

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

Settings Backup

+
+

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

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

API Keys Management

+
+

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

+
+ + + {isDownloadingTemplate ? ( +
+ ) : ( +
+ )} + Download Template + + apiKeyFileInputRef.current?.click()} + disabled={isImportingKeys} + > + {isImportingKeys ? ( +
+ ) : ( +
+ )} + Import API Keys + +
+
); } diff --git a/app/components/settings/debug/DebugTab.tsx b/app/components/settings/debug/DebugTab.tsx index aca22e10..e8e74e60 100644 --- a/app/components/settings/debug/DebugTab.tsx +++ b/app/components/settings/debug/DebugTab.tsx @@ -2,6 +2,9 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useSettings } from '~/lib/hooks/useSettings'; import { toast } from 'react-toastify'; import { providerBaseUrlEnvKeys } from '~/utils/constants'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; +import { settingsStyles } from '~/components/settings/settings.styles'; interface ProviderStatus { name: string; @@ -438,107 +441,182 @@ export default function DebugTab() { }, [activeProviders, systemInfo, isLatestBranch]); return ( -
+
-

Debug Information

+
+
+

Debug Information

+
- - + {isCheckingUpdate ? ( + <> +
+ Checking... + + ) : ( + <> +
+ Check for Updates + + )} +
{updateMessage && ( -
-

{updateMessage}

- {updateMessage.includes('Update available') && ( -
-

To update:

-
    -
  1. - Pull the latest changes:{' '} - git pull upstream main -
  2. -
  3. - Install any new dependencies:{' '} - pnpm install -
  4. -
  5. Restart the application
  6. -
-
+ + initial={{ opacity: 0, y: -20 }} + animate={{ opacity: 1, y: 0 }} + > +
+
+
+

{updateMessage}

+ {updateMessage.includes('Update available') && ( +
+

To update:

+
    +
  1. +
    +
    + Pull the latest changes:{' '} + + git pull upstream main + +
    +
  2. +
  3. +
    +
    + Install any new dependencies:{' '} + + pnpm install + +
    +
  4. +
  5. +
    +
    + Restart the application +
    +
  6. +
+
+ )} +
+
+ )}
-
-

System Information

-
+ +
+
+

System Information

+
+
-

Operating System

+
+
+

Operating System

+

{systemInfo.os}

-

Device Type

+
+
+

Device Type

+

{systemInfo.deviceType}

-

Browser

+
+
+

Browser

+

{systemInfo.browser}

-

Display

+
+
+

Display

+

{systemInfo.screen} ({systemInfo.colorDepth}) @{systemInfo.pixelRatio}x

-

Connection

-

+

+
+

Connection

+
+
- + {systemInfo.online ? 'Online' : 'Offline'} -

+
-

Screen Resolution

-

{systemInfo.screen}

-
-
-

Language

+
+
+

Language

+

{systemInfo.language}

-

Timezone

+
+
+

Timezone

+

{systemInfo.timezone}

-

CPU Cores

+
+
+

CPU Cores

+

{systemInfo.cores}

-
-

Version

+
+
+
+

Version

+

{connitJson.commit.slice(0, 7)} @@ -546,22 +624,31 @@ export default function DebugTab() {

-
-
+ + -
-

Local LLM Status

-
-
+ +
+
+

Local LLM Status

+
+ +
{activeProviders.map((provider) => ( -
+
@@ -575,17 +662,21 @@ export default function DebugTab() {
{provider.enabled ? 'Enabled' : 'Disabled'} {provider.enabled && ( {provider.isRunning ? 'Running' : 'Not Running'} @@ -593,31 +684,28 @@ export default function DebugTab() {
-
- {/* Status Details */} +
- + Last checked: {new Date(provider.lastChecked).toLocaleTimeString()} {provider.responseTime && ( - + Response time: {Math.round(provider.responseTime)}ms )}
- {/* Error Message */} {provider.error && ( -
+
Error: {provider.error}
)} - {/* Connection Info */} {provider.url && ( -
+
Endpoints checked: -
    +
    • {provider.url} (root)
    • {provider.url}/api/health
    • {provider.url}/v1/models
    • @@ -631,8 +719,8 @@ export default function DebugTab() {
      No local LLMs configured
      )}
-
-
+ +
); diff --git a/app/components/settings/event-logs/EventLogsTab.tsx b/app/components/settings/event-logs/EventLogsTab.tsx index 5c1ed44a..da092243 100644 --- a/app/components/settings/event-logs/EventLogsTab.tsx +++ b/app/components/settings/event-logs/EventLogsTab.tsx @@ -1,22 +1,27 @@ -import React, { useCallback, useEffect, useState, useMemo } from 'react'; +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); + const logs = useStore(logStore.logs); const [logLevel, setLogLevel] = useState('info'); const [autoScroll, setAutoScroll] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [, forceUpdate] = useState({}); + const logsContainerRef = useRef(null); + const [isScrolledToBottom, setIsScrolledToBottom] = useState(true); const filteredLogs = useMemo(() => { - const logs = logStore.getLogs(); - return logs.filter((log) => { + const allLogs = Object.values(logs); + const filtered = allLogs.filter((log) => { const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all'; const matchesSearch = !searchQuery || @@ -25,7 +30,9 @@ export default function EventLogsTab() { return matchesLevel && matchesSearch; }); - }, [logLevel, searchQuery]); + + return filtered.reverse(); + }, [logs, logLevel, searchQuery]); // Effect to initialize showLogs useEffect(() => { @@ -37,18 +44,51 @@ export default function EventLogsTab() { 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'], + 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 @@ -56,16 +96,50 @@ export default function EventLogsTab() { 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 = document.querySelector('.logs-container'); + const container = logsContainerRef.current; - if (container && autoScroll) { - container.scrollTop = container.scrollHeight; + if (!container) { + return undefined; } - }, [filteredLogs, autoScroll]); + + 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]); const handleClearLogs = useCallback(() => { if (confirm('Are you sure you want to clear all logs?')) { @@ -103,33 +177,56 @@ 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 getLevelColor = (level: LogEntry['level']) => { switch (level) { case 'info': - return 'text-blue-500'; + return 'text-[#1389FD] dark:text-[#1389FD]'; case 'warning': - return 'text-yellow-500'; + return 'text-[#FFDB6C] dark:text-[#FFDB6C]'; case 'error': - return 'text-red-500'; + return 'text-[#EE4744] dark:text-[#EE4744]'; case 'debug': - return 'text-gray-500'; + return 'text-[#77828D] dark:text-[#77828D]'; default: return 'text-bolt-elements-textPrimary'; } }; return ( -
-
+
+
{/* Title and Toggles Row */}
-

Event Logs

+
+
+
+

Event Logs

+

Track system events and debug information

+
+
-
+
+
Show Actions logStore.showLogs.set(checked)} />
-
+
+
Auto-scroll
@@ -137,83 +234,166 @@ export default function EventLogsTab() {
{/* Controls Row */} -
- +
+
+
+ +
+
+
- setSearchQuery(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> +
+ 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', + )} + /> +
+
{showLogs && (
- - +
)}
-
- {filteredLogs.length === 0 ? ( -
No logs found
- ) : ( - filteredLogs.map((log, index) => ( -
-
- - [{log.level.toUpperCase()}] - - - {new Date(log.timestamp).toLocaleString()} - - {log.message} -
- {log.details && ( -
-                  {JSON.stringify(log.details, null, 2)}
-                
- )} -
- )) + + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + > + {filteredLogs.length === 0 ? ( +
+ + + No logs found + +
+ ) : ( +
+ {filteredLogs.map((log, index) => ( + +
+
+
+
+ + {log.level.toUpperCase()} + + + {new Date(log.timestamp).toLocaleString()} + + {log.message} +
+ {log.details && ( + + {JSON.stringify(log.details, null, 2)} + + )} +
+
+ + ))} +
+ )} +
); } diff --git a/app/components/settings/features/FeaturesTab.tsx b/app/components/settings/features/FeaturesTab.tsx index f67ddc89..93d8d7dd 100644 --- a/app/components/settings/features/FeaturesTab.tsx +++ b/app/components/settings/features/FeaturesTab.tsx @@ -1,7 +1,22 @@ -import React from 'react'; +import React, { useState } from 'react'; 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'; + +interface FeatureToggle { + id: string; + title: string; + description: string; + icon: string; + enabled: boolean; + beta?: boolean; + experimental?: boolean; + tooltip?: string; +} export default function FeaturesTab() { const { @@ -20,88 +35,266 @@ export default function FeaturesTab() { contextOptimizationEnabled, } = useSettings(); + const [hoveredFeature, setHoveredFeature] = useState(null); + const [expandedFeature, setExpandedFeature] = useState(null); + const handleToggle = (enabled: boolean) => { enableDebugMode(enabled); enableEventLogs(enabled); + toast.success(`Debug features ${enabled ? 'enabled' : 'disabled'}`); + }; + + 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', + description: 'Check for updates against the main branch instead of stable', + icon: 'i-ph:git-branch', + enabled: isLatestBranch, + beta: true, + tooltip: 'Get the latest features and improvements before they are officially released', + }, + { + id: 'autoTemplate', + title: 'Auto Select Code Template', + description: 'Let Bolt select the best starter template for your project', + icon: 'i-ph:magic-wand', + enabled: autoSelectTemplate, + tooltip: 'Automatically choose the most suitable template based on your project type', + }, + { + id: 'contextOptimization', + title: 'Context Optimization', + description: 'Optimize chat context by redacting file contents and using system prompts', + icon: 'i-ph:arrows-in', + enabled: contextOptimizationEnabled, + tooltip: 'Improve AI responses by optimizing the context window and system prompts', + }, + { + id: 'experimentalProviders', + title: 'Experimental Providers', + description: 'Enable experimental providers like Ollama, LMStudio, and OpenAILike', + icon: 'i-ph:robot', + enabled: isLocalModel, + experimental: true, + tooltip: 'Try out new AI providers and models in development', + }, + ]; + + 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'}`); + break; + case 'autoTemplate': + setAutoSelectTemplate(enabled); + toast.success(`Auto template selection ${enabled ? 'enabled' : 'disabled'}`); + break; + case 'contextOptimization': + enableContextOptimization(enabled); + toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`); + break; + case 'experimentalProviders': + enableLocalModels(enabled); + toast.success(`Experimental providers ${enabled ? 'enabled' : 'disabled'}`); + break; + } }; return ( -
-
-

Optional Features

-
-
- Debug Features - -
-
-
- Use Main Branch -

- Check for updates against the main branch instead of stable -

-
- -
-
-
- Auto Select Code Template -

- Let Bolt select the best starter template for your project. -

-
- -
-
-
- Use Context Optimization -

- redact file contents form chat and puts the latest file contents on the system prompt -

-
- -
+
+ +
+
+

Features

+

Customize your Bolt experience

-
+
-
-

Experimental Features

-

- Disclaimer: Experimental features may be unstable and are subject to change. -

-
-
- Experimental Providers - -
-

- Enable experimental providers such as Ollama, LMStudio, and OpenAILike. -

-
-
-
- Prompt Library -

- Choose a prompt from the library to use as the system prompt. -

-
- + + {hoveredFeature === feature.id && feature.tooltip && ( + + {feature.tooltip} +
+ + )} + + +
+ {feature.beta && ( + + Beta + + )} + {feature.experimental && ( + + Experimental + + )} +
+ +
+ +
+ + +
+
+
+

+ {feature.title} +

+

{feature.description}

+
+ handleToggleFeature(feature.id, checked)} + /> +
+
+
+ + + + ))} +
+ + +
+ +
+ + +
+
+
+

+ Prompt Library +

+

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

+
+ +
+
-
+
); } diff --git a/app/components/settings/profile/ProfileTab.tsx b/app/components/settings/profile/ProfileTab.tsx new file mode 100644 index 00000000..a3c02bb5 --- /dev/null +++ b/app/components/settings/profile/ProfileTab.tsx @@ -0,0 +1,399 @@ +import React, { useState, useRef, useEffect } 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 +const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif']; +const MIN_PASSWORD_LENGTH = 8; + +export default function ProfileTab() { + const fileInputRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [currentTimezone, setCurrentTimezone] = useState(''); + const [profile, setProfile] = useState(() => { + const saved = localStorage.getItem('bolt_user_profile'); + return saved + ? JSON.parse(saved) + : { + 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) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + if (!ALLOWED_FILE_TYPES.includes(file.type)) { + toast.error('Please upload a valid image file (JPEG, PNG, or GIF)'); + return; + } + + if (file.size > MAX_FILE_SIZE) { + toast.error('File size must be less than 5MB'); + return; + } + + setIsLoading(true); + + try { + const reader = new FileReader(); + + reader.onloadend = () => { + setProfile((prev) => ({ ...prev, avatar: reader.result as string })); + setIsLoading(false); + }; + reader.readAsDataURL(file); + } catch (error) { + console.error('Error uploading avatar:', error); + toast.error('Failed to upload avatar'); + setIsLoading(false); + } + }; + + const handleSave = async () => { + if (!profile.name.trim()) { + toast.error('Name is required'); + return; + } + + if (!profile.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profile.email)) { + toast.error('Please enter a valid email address'); + return; + } + + if (profile.password && profile.password.length < MIN_PASSWORD_LENGTH) { + toast.error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters long`); + return; + } + + setIsLoading(true); + + try { + localStorage.setItem('bolt_user_profile', JSON.stringify(profile)); + toast.success('Profile settings saved successfully'); + } catch (error) { + console.error('Error saving profile:', error); + toast.error('Failed to save profile settings'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ {/* Profile Information */} + +
+
+ Personal Information +
+
+ {/* Avatar */} +
+
+ + {isLoading ? ( +
+ ) : profile.avatar ? ( + Profile + ) : ( +
+ )} + +
+ + +
+ + {/* Profile Fields */} +
+
+
+
+
+ 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', + )} + /> +
+ +
+
+
+
+ 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', + )} + /> +
+ +
+ 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', + )} + /> + +
+
+
+ + + {/* Theme & Language */} + +
+
+ Appearance +
+ +
+
+
+ +
+
+ {(['light', 'dark', 'system'] as const).map((theme) => ( + + ))} +
+
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+
+ + {profile.notifications ? 'Notifications are enabled' : 'Notifications are disabled'} + + setProfile((prev) => ({ ...prev, notifications: checked }))} + /> +
+
+ + + {/* Timezone */} +
+
+
+ Time Settings +
+ +
+
+ +
+
+ + +
+
+
+ + {/* Save Button */} + + + +
+ ); +} diff --git a/app/components/settings/providers/OllamaModelUpdater.tsx b/app/components/settings/providers/OllamaModelUpdater.tsx new file mode 100644 index 00000000..110ffb4d --- /dev/null +++ b/app/components/settings/providers/OllamaModelUpdater.tsx @@ -0,0 +1,295 @@ +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; +import { classNames } from '~/utils/classNames'; +import { settingsStyles } from '~/components/settings/settings.styles'; +import { DialogTitle, DialogDescription } from '~/components/ui/Dialog'; + +interface OllamaModel { + name: string; + digest: string; + size: number; + modified_at: string; + details?: { + family: string; + parameter_size: string; + quantization_level: string; + }; + status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking'; + error?: string; + newDigest?: string; + progress?: { + current: number; + total: number; + status: string; + }; +} + +interface OllamaTagResponse { + models: Array<{ + name: string; + digest: string; + size: number; + modified_at: string; + details?: { + family: string; + parameter_size: string; + quantization_level: string; + }; + }>; +} + +interface OllamaPullResponse { + status: string; + digest?: string; + total?: number; + completed?: number; +} + +export default function OllamaModelUpdater() { + const [models, setModels] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isBulkUpdating, setIsBulkUpdating] = useState(false); + + useEffect(() => { + fetchModels(); + }, []); + + const fetchModels = async () => { + try { + setIsLoading(true); + + const response = await fetch('http://localhost:11434/api/tags'); + const data = (await response.json()) as OllamaTagResponse; + setModels( + data.models.map((model) => ({ + name: model.name, + digest: model.digest, + size: model.size, + modified_at: model.modified_at, + details: model.details, + status: 'idle' as const, + })), + ); + } catch (error) { + toast.error('Failed to fetch Ollama models'); + console.error('Error fetching models:', error); + } finally { + setIsLoading(false); + } + }; + + const updateModel = async (modelName: string): Promise<{ success: boolean; newDigest?: string }> => { + try { + const response = await fetch('http://localhost:11434/api/pull', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: modelName }), + }); + + if (!response.ok) { + throw new Error(`Failed to update ${modelName}`); + } + + const reader = response.body?.getReader(); + + if (!reader) { + throw new Error('No response reader available'); + } + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + const text = new TextDecoder().decode(value); + const lines = text.split('\n').filter(Boolean); + + for (const line of lines) { + const data = JSON.parse(line) as OllamaPullResponse; + + setModels((current) => + current.map((m) => + m.name === modelName + ? { + ...m, + progress: { + current: data.completed || 0, + total: data.total || 0, + status: data.status, + }, + newDigest: data.digest, + } + : m, + ), + ); + } + } + + setModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'checking' } : m))); + + const updatedResponse = await fetch('http://localhost:11434/api/tags'); + const data = (await updatedResponse.json()) as OllamaTagResponse; + const updatedModel = data.models.find((m) => m.name === modelName); + + return { success: true, newDigest: updatedModel?.digest }; + } catch (error) { + console.error(`Error updating ${modelName}:`, error); + return { success: false }; + } + }; + + const handleBulkUpdate = async () => { + setIsBulkUpdating(true); + + for (const model of models) { + setModels((current) => current.map((m) => (m.name === model.name ? { ...m, status: 'updating' } : m))); + + const { success, newDigest } = await updateModel(model.name); + + setModels((current) => + current.map((m) => + m.name === model.name + ? { + ...m, + status: success ? 'updated' : 'error', + error: success ? undefined : 'Update failed', + newDigest, + } + : m, + ), + ); + } + + setIsBulkUpdating(false); + toast.success('Bulk update completed'); + }; + + const handleSingleUpdate = async (modelName: string) => { + setModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m))); + + const { success, newDigest } = await updateModel(modelName); + + setModels((current) => + current.map((m) => + m.name === modelName + ? { + ...m, + status: success ? 'updated' : 'error', + error: success ? undefined : 'Update failed', + newDigest, + } + : m, + ), + ); + + if (success) { + toast.success(`Updated ${modelName}`); + } else { + toast.error(`Failed to update ${modelName}`); + } + }; + + if (isLoading) { + return ( +
+
+ Loading models... +
+ ); + } + + return ( +
+
+ Ollama Model Manager + Update your local Ollama models to their latest versions +
+ +
+
+
+ {models.length} models available +
+ + {isBulkUpdating ? ( + <> +
+ Updating All... + + ) : ( + <> +
+ Update All Models + + )} + +
+ +
+ {models.map((model) => ( +
+
+
+
+ {model.name} + {model.status === 'updating' &&
} + {model.status === 'updated' &&
} + {model.status === 'error' &&
} +
+
+ Version: {model.digest.substring(0, 7)} + {model.status === 'updated' && model.newDigest && ( + <> +
+ {model.newDigest.substring(0, 7)} + + )} + {model.progress && ( + + {model.progress.status}{' '} + {model.progress.total > 0 && ( + <>({Math.round((model.progress.current / model.progress.total) * 100)}%) + )} + + )} + {model.details && ( + + ({model.details.parameter_size}, {model.details.quantization_level}) + + )} +
+
+ handleSingleUpdate(model.name)} + disabled={model.status === 'updating'} + className={classNames(settingsStyles.button.base, settingsStyles.button.secondary)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > +
+ Update + +
+ ))} +
+
+ ); +} diff --git a/app/components/settings/providers/ProvidersTab.tsx b/app/components/settings/providers/ProvidersTab.tsx index 2f790bc8..7818fbf5 100644 --- a/app/components/settings/providers/ProvidersTab.tsx +++ b/app/components/settings/providers/ProvidersTab.tsx @@ -1,34 +1,157 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Switch } from '~/components/ui/Switch'; +import Separator from '~/components/ui/Separator'; import { useSettings } from '~/lib/hooks/useSettings'; import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings'; import type { IProviderConfig } from '~/types/model'; import { logStore } from '~/lib/stores/logs'; - -// Import a default fallback icon +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; +import { settingsStyles } from '~/components/settings/settings.styles'; +import { toast } from 'react-toastify'; import { providerBaseUrlEnvKeys } from '~/utils/constants'; +import { SiAmazon, SiOpenai, SiGoogle, SiHuggingface, SiPerplexity } from 'react-icons/si'; +import { BsRobot, BsCloud, BsCodeSquare, BsCpu, BsBox } from 'react-icons/bs'; +import { TbBrandOpenai, TbBrain, TbCloudComputing } from 'react-icons/tb'; +import { BiCodeBlock, BiChip } from 'react-icons/bi'; +import { FaCloud, FaBrain } from 'react-icons/fa'; +import type { IconType } from 'react-icons'; +import OllamaModelUpdater from './OllamaModelUpdater'; +import { DialogRoot, Dialog } from '~/components/ui/Dialog'; -const DefaultIcon = '/icons/Default.svg'; // Adjust the path as necessary +// Add type for provider names to ensure type safety +type ProviderName = + | 'AmazonBedrock' + | 'Anthropic' + | 'Cohere' + | 'Deepseek' + | 'Google' + | 'Groq' + | 'HuggingFace' + | 'Hyperbolic' + | 'LMStudio' + | 'Mistral' + | 'Ollama' + | 'OpenAI' + | 'OpenAILike' + | 'OpenRouter' + | 'Perplexity' + | 'Together' + | 'XAI'; + +// Update the PROVIDER_ICONS type to use the ProviderName type +const PROVIDER_ICONS: Record = { + AmazonBedrock: SiAmazon, + Anthropic: FaBrain, + Cohere: BiChip, + Deepseek: BiCodeBlock, + Google: SiGoogle, + Groq: BsCpu, + HuggingFace: SiHuggingface, + Hyperbolic: TbCloudComputing, + LMStudio: BsCodeSquare, + Mistral: TbBrain, + Ollama: BsBox, + OpenAI: SiOpenai, + OpenAILike: TbBrandOpenai, + OpenRouter: FaCloud, + Perplexity: SiPerplexity, + Together: BsCloud, + XAI: BsRobot, +}; + +// Update PROVIDER_DESCRIPTIONS to use the same type +const PROVIDER_DESCRIPTIONS: Partial> = { + OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models', + Anthropic: 'Access Claude and other Anthropic models', + Ollama: 'Run open-source models locally on your machine', + LMStudio: 'Local model inference with LM Studio', + OpenAILike: 'Connect to OpenAI-compatible API endpoints', +}; + +// Add these types and helper functions +type ProviderCategory = 'cloud' | 'local'; + +interface ProviderGroup { + title: string; + description: string; + icon: string; + providers: IProviderConfig[]; +} + +// Add this type +interface CategoryToggleState { + cloud: boolean; + local: boolean; +} export default function ProvidersTab() { const { providers, updateProviderSettings, isLocalModel } = useSettings(); + const [editingProvider, setEditingProvider] = useState(null); const [filteredProviders, setFilteredProviders] = useState([]); + const [categoryEnabled, setCategoryEnabled] = useState({ + cloud: false, + local: false, + }); + const [showOllamaUpdater, setShowOllamaUpdater] = useState(false); - // Load base URLs from cookies - const [searchTerm, setSearchTerm] = useState(''); + // Group providers by category + const groupedProviders = useMemo(() => { + const groups: Record = { + cloud: { + title: 'Cloud Providers', + description: 'AI models hosted on cloud platforms', + icon: 'i-ph:cloud-duotone', + providers: [], + }, + local: { + title: 'Local Providers', + description: 'Run models locally on your machine', + icon: 'i-ph:desktop-duotone', + providers: [], + }, + }; + filteredProviders.forEach((provider) => { + const category: ProviderCategory = LOCAL_PROVIDERS.includes(provider.name) ? 'local' : 'cloud'; + groups[category].providers.push(provider); + }); + + return groups; + }, [filteredProviders]); + + // Update the toggle handler + const handleToggleCategory = useCallback( + (category: ProviderCategory, enabled: boolean) => { + setCategoryEnabled((prev) => ({ ...prev, [category]: enabled })); + + // Get providers for this category + const categoryProviders = groupedProviders[category].providers; + categoryProviders.forEach((provider) => { + updateProviderSettings(provider.name, { ...provider.settings, enabled }); + }); + + toast.success(enabled ? `All ${category} providers enabled` : `All ${category} providers disabled`); + }, + [groupedProviders, updateProviderSettings], + ); + + // Add effect to update category toggle states based on provider states + useEffect(() => { + const newCategoryState = { + cloud: groupedProviders.cloud.providers.every((p) => p.settings.enabled), + local: groupedProviders.local.providers.every((p) => p.settings.enabled), + }; + setCategoryEnabled(newCategoryState); + }, [groupedProviders]); + + // Effect to filter and sort providers useEffect(() => { let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({ ...value, name: key, })); - if (searchTerm && searchTerm.length > 0) { - newFilteredProviders = newFilteredProviders.filter((provider) => - provider.name.toLowerCase().includes(searchTerm.toLowerCase()), - ); - } - if (!isLocalModel) { newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name)); } @@ -40,108 +163,245 @@ export default function ProvidersTab() { const urlConfigurable = newFilteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name)); setFilteredProviders([...regular, ...urlConfigurable]); - }, [providers, searchTerm, isLocalModel]); + }, [providers, isLocalModel]); - const renderProviderCard = (provider: IProviderConfig) => { - const envBaseUrlKey = providerBaseUrlEnvKeys[provider.name].baseUrlKey; - const envBaseUrl = envBaseUrlKey ? import.meta.env[envBaseUrlKey] : undefined; - const isUrlConfigurable = URL_CONFIGURABLE_PROVIDERS.includes(provider.name); + const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => { + updateProviderSettings(provider.name, { ...provider.settings, enabled }); - return ( -
-
-
- { - e.currentTarget.src = DefaultIcon; - }} - alt={`${provider.name} icon`} - className="w-6 h-6 dark:invert" - /> - {provider.name} -
- { - updateProviderSettings(provider.name, { ...provider.settings, enabled }); - - if (enabled) { - logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name }); - } else { - logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name }); - } - }} - /> -
- {isUrlConfigurable && provider.settings.enabled && ( -
- {envBaseUrl && ( - - )} - - { - let newBaseUrl: string | undefined = e.target.value; - - if (newBaseUrl && newBaseUrl.trim().length === 0) { - newBaseUrl = undefined; - } - - updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl }); - logStore.logProvider(`Base URL updated for ${provider.name}`, { - provider: provider.name, - baseUrl: newBaseUrl, - }); - }} - placeholder={`Enter ${provider.name} base URL`} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> -
- )} -
- ); + if (enabled) { + logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name }); + toast.success(`${provider.name} enabled`); + } else { + logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name }); + toast.success(`${provider.name} disabled`); + } }; - const regularProviders = filteredProviders.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name)); - const urlConfigurableProviders = filteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name)); + const handleUpdateBaseUrl = (provider: IProviderConfig, baseUrl: string) => { + let newBaseUrl: string | undefined = baseUrl; + + if (newBaseUrl && newBaseUrl.trim().length === 0) { + newBaseUrl = undefined; + } + + updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl }); + logStore.logProvider(`Base URL updated for ${provider.name}`, { + provider: provider.name, + baseUrl: newBaseUrl, + }); + toast.success(`${provider.name} base URL updated`); + setEditingProvider(null); + }; return ( -
-
- setSearchTerm(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> -
+
+ {Object.entries(groupedProviders).map(([category, group]) => ( + +
+
+
+
+
+
+

{group.title}

+

{group.description}

+
+
- {/* Regular Providers Grid */} -
{regularProviders.map(renderProviderCard)}
+
+ + Enable All {category === 'cloud' ? 'Cloud' : 'Local'} + + handleToggleCategory(category as ProviderCategory, checked)} + /> +
+
- {/* URL Configurable Providers Section */} - {urlConfigurableProviders.length > 0 && ( -
-

Experimental Providers

-

- These providers are experimental and allow you to run AI models locally or connect to your own - infrastructure. They require additional setup but offer more flexibility. -

-
{urlConfigurableProviders.map(renderProviderCard)}
-
- )} +
+ {group.providers.map((provider, index) => ( + +
+ {LOCAL_PROVIDERS.includes(provider.name) && ( + + Local + + )} + {URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && ( + + Configurable + + )} +
+ +
+ +
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, { + className: 'w-full h-full', + 'aria-label': `${provider.name} logo`, + })} +
+
+ +
+
+
+

+ {provider.name} +

+

+ {PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS] || + (URL_CONFIGURABLE_PROVIDERS.includes(provider.name) + ? 'Configure custom endpoint for this provider' + : 'Standard AI provider integration')} +

+
+ handleToggleProvider(provider, checked)} + /> +
+ + {provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && ( + +
+ {editingProvider === provider.name ? ( + { + if (e.key === 'Enter') { + handleUpdateBaseUrl(provider, e.currentTarget.value); + } else if (e.key === 'Escape') { + setEditingProvider(null); + } + }} + onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)} + autoFocus + /> + ) : ( +
setEditingProvider(provider.name)} + > +
+
+ + {provider.settings.baseUrl || 'Click to set base URL'} + +
+
+ )} +
+ + {providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && ( +
+
+
+ Environment URL set in .env file +
+
+ )} + + )} +
+
+ + + + {provider.name === 'Ollama' && provider.settings.enabled && ( + setShowOllamaUpdater(true)} + className={classNames(settingsStyles.button.base, settingsStyles.button.secondary, 'ml-2')} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > +
+ Update Models + + )} + + + +
+ +
+
+
+ + ))} +
+ + {category === 'cloud' && } +
+ ))}
); } diff --git a/app/components/settings/settings.styles.ts b/app/components/settings/settings.styles.ts new file mode 100644 index 00000000..ddb9c226 --- /dev/null +++ b/app/components/settings/settings.styles.ts @@ -0,0 +1,37 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export const settingsStyles = { + // Card styles + card: 'bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]', + + // 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', + 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', + }, + + // Form styles + form: { + label: 'block text-sm text-bolt-elements-textSecondary 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', + }, + + // 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', + }, + + 'loading-spinner': 'i-ph:spinner-gap-bold animate-spin w-4 h-4', +} as const; diff --git a/app/components/settings/settings.types.ts b/app/components/settings/settings.types.ts new file mode 100644 index 00000000..3037ae82 --- /dev/null +++ b/app/components/settings/settings.types.ts @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react'; + +export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences'; +export type TabType = + | 'profile' + | 'data' + | 'providers' + | 'features' + | 'debug' + | 'event-logs' + | 'connection' + | 'preferences'; + +export interface UserProfile { + name: string; + email: string; + avatar?: string; + theme: 'light' | 'dark' | 'system'; + notifications: boolean; + password?: string; + bio?: string; + language: string; + timezone: string; +} + +export interface SettingItem { + id: TabType; + label: string; + icon: string; + category: SettingCategory; + description?: string; + component: () => ReactNode; + badge?: string; + keywords?: string[]; +} + +export const categoryLabels: Record = { + profile: 'Profile & Account', + file_sharing: 'File Sharing', + connectivity: 'Connectivity', + system: 'System', + services: 'Services', + preferences: 'Preferences', +}; + +export const categoryIcons: Record = { + profile: 'i-ph:user-circle', + file_sharing: 'i-ph:folder-simple', + connectivity: 'i-ph:wifi-high', + system: 'i-ph:gear', + services: 'i-ph:cube', + preferences: 'i-ph:sliders', +}; diff --git a/app/components/ui/Dialog.tsx b/app/components/ui/Dialog.tsx index a808c774..0ea110db 100644 --- a/app/components/ui/Dialog.tsx +++ b/app/components/ui/Dialog.tsx @@ -7,6 +7,51 @@ import { IconButton } from './IconButton'; export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog'; +interface DialogButtonProps { + type: 'primary' | 'secondary' | 'danger'; + children: ReactNode; + onClick?: (event: React.MouseEvent) => void; + disabled?: boolean; +} + +export const DialogButton = memo(({ type, children, onClick, disabled }: DialogButtonProps) => { + return ( + + ); +}); + +export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => { + return ( + + {children} + + ); +}); + +export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => { + return ( + + {children} + + ); +}); + const transition = { duration: 0.15, ease: cubicEasingFn, @@ -40,81 +85,39 @@ export const dialogVariants = { }, } satisfies Variants; -interface DialogButtonProps { - type: 'primary' | 'secondary' | 'danger'; - children: ReactNode; - onClick?: (event: React.UIEvent) => void; -} - -export const DialogButton = memo(({ type, children, onClick }: DialogButtonProps) => { - return ( - - ); -}); - -export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => { - return ( - - {children} - - ); -}); - -export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => { - return ( - - {children} - - ); -}); - interface DialogProps { - children: ReactNode | ReactNode[]; + children: ReactNode; className?: string; - onBackdrop?: (event: React.UIEvent) => void; - onClose?: (event: React.UIEvent) => void; + showCloseButton?: boolean; + onClose?: () => void; + onBackdrop?: () => void; } -export const Dialog = memo(({ className, children, onBackdrop, onClose }: DialogProps) => { +export const Dialog = memo(({ children, className, showCloseButton = true, onClose, onBackdrop }: DialogProps) => { return ( - + - {children} - - - +
+ {children} + {showCloseButton && ( + + + + )} +
diff --git a/app/components/ui/Separator.tsx b/app/components/ui/Separator.tsx new file mode 100644 index 00000000..8ea43a5e --- /dev/null +++ b/app/components/ui/Separator.tsx @@ -0,0 +1,22 @@ +import * as SeparatorPrimitive from '@radix-ui/react-separator'; +import { classNames } from '~/utils/classNames'; + +interface SeparatorProps { + className?: string; + orientation?: 'horizontal' | 'vertical'; +} + +export const Separator = ({ className, orientation = 'horizontal' }: SeparatorProps) => { + return ( + + ); +}; + +export default Separator; diff --git a/app/lib/stores/logs.ts b/app/lib/stores/logs.ts index 4b306a5e..1af2506a 100644 --- a/app/lib/stores/logs.ts +++ b/app/lib/stores/logs.ts @@ -24,6 +24,11 @@ class LogStore { this._loadLogs(); } + // Expose the logs store for subscription + get logs() { + return this._logs; + } + private _loadLogs() { const savedLogs = Cookies.get('eventLogs'); diff --git a/package.json b/package.json index 5788c003..a259ab5a 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,12 @@ "node": ">=18.18.0" }, "dependencies": { + "@ai-sdk/amazon-bedrock": "1.0.6", "@ai-sdk/anthropic": "^0.0.39", "@ai-sdk/cohere": "^1.0.3", "@ai-sdk/google": "^0.0.52", "@ai-sdk/mistral": "^0.0.43", "@ai-sdk/openai": "^0.0.66", - "@ai-sdk/amazon-bedrock": "1.0.6", "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-cpp": "^6.0.2", @@ -52,7 +52,7 @@ "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.35.0", - "@iconify-json/ph": "^1.2.1", + "@headlessui/react": "^2.2.0", "@iconify-json/svg-spinners": "^1.2.1", "@lezer/highlight": "^1.2.1", "@nanostores/react": "^0.7.3", @@ -76,6 +76,7 @@ "@xterm/xterm": "^5.5.0", "ai": "^4.0.13", "chalk": "^5.4.1", + "clsx": "^2.1.1", "date-fns": "^3.6.0", "diff": "^5.2.0", "dotenv": "^16.4.7", @@ -93,6 +94,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hotkeys-hook": "^4.6.1", + "react-icons": "^5.4.0", "react-markdown": "^9.0.1", "react-resizable-panels": "^2.1.7", "react-toastify": "^10.0.6", @@ -102,11 +104,14 @@ "remix-island": "^0.2.0", "remix-utils": "^7.7.0", "shiki": "^1.24.0", + "tailwind-merge": "^2.6.0", "unist-util-visit": "^5.0.0" }, "devDependencies": { "@blitz/eslint-plugin": "0.1.0", "@cloudflare/workers-types": "^4.20241127.0", + "@iconify-json/ph": "^1.2.1", + "@iconify/types": "^2.0.0", "@remix-run/dev": "^2.15.0", "@types/diff": "^5.2.3", "@types/dom-speech-recognition": "^0.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f625a94c..c4d52d1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,9 +77,9 @@ importers: '@codemirror/view': specifier: ^6.35.0 version: 6.35.0 - '@iconify-json/ph': - specifier: ^1.2.1 - version: 1.2.1 + '@headlessui/react': + specifier: ^2.2.0 + version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@iconify-json/svg-spinners': specifier: ^1.2.1 version: 1.2.1 @@ -149,6 +149,9 @@ importers: chalk: specifier: ^5.4.1 version: 5.4.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 date-fns: specifier: ^3.6.0 version: 3.6.0 @@ -200,6 +203,9 @@ importers: react-hotkeys-hook: specifier: ^4.6.1 version: 4.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-icons: + specifier: ^5.4.0 + version: 5.4.0(react@18.3.1) react-markdown: specifier: ^9.0.1 version: 9.0.1(@types/react@18.3.12)(react@18.3.1) @@ -227,6 +233,9 @@ importers: shiki: specifier: ^1.24.0 version: 1.24.0 + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 unist-util-visit: specifier: ^5.0.0 version: 5.0.0 @@ -237,6 +246,12 @@ importers: '@cloudflare/workers-types': specifier: ^4.20241127.0 version: 4.20241127.0 + '@iconify-json/ph': + specifier: ^1.2.1 + version: 1.2.1 + '@iconify/types': + specifier: ^2.0.0 + version: 2.0.0 '@remix-run/dev': specifier: ^2.15.0 version: 2.15.0(@remix-run/react@2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2))(@types/node@22.10.1)(sass-embedded@1.81.0)(typescript@5.7.2)(vite@5.4.11(@types/node@22.10.1)(sass-embedded@1.81.0))(wrangler@3.91.0(@cloudflare/workers-types@4.20241127.0)) @@ -1437,9 +1452,22 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/utils@0.2.8': resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@headlessui/react@2.2.0': + resolution: {integrity: sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1997,6 +2025,40 @@ packages: '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@react-aria/focus@3.19.1': + resolution: {integrity: sha512-bix9Bu1Ue7RPcYmjwcjhB14BMu2qzfJ3tMQLqDc9pweJA66nOw8DThy3IfVr8Z7j2PHktOLf9kcbiZpydKHqzg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/interactions@3.23.0': + resolution: {integrity: sha512-0qR1atBIWrb7FzQ+Tmr3s8uH5mQdyRH78n0krYaG8tng9+u1JlSi8DGRSaC9ezKyNB84m7vHT207xnHXGeJ3Fg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/ssr@3.9.7': + resolution: {integrity: sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==} + engines: {node: '>= 12'} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/utils@3.27.0': + resolution: {integrity: sha512-p681OtApnKOdbeN8ITfnnYqfdHS0z7GE+4l8EXlfLnr70Rp/9xicBO6d2rU+V/B3JujDw2gPWxYKEnEeh0CGCw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-stately/utils@3.10.5': + resolution: {integrity: sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-types/shared@3.27.0': + resolution: {integrity: sha512-gvznmLhi6JPEf0bsq7SwRYTHAKKq/wcmKqFez9sRdbED+SPMUmK5omfZ6w3EwUFQHbYUa4zPBYedQ7Knv70RMw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@remix-run/cloudflare-pages@2.15.0': resolution: {integrity: sha512-3FjiON0BmEH3fwGdmP6eEf9TL5BejCt9LOMnszefDGdwY7kgXCodJNr8TAYseor6m7LlC4xgSkgkgj/YRIZTGA==} engines: {node: '>=18.0.0'} @@ -2398,6 +2460,18 @@ packages: peerDependencies: eslint: '>=8.40.0' + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tanstack/react-virtual@3.11.2': + resolution: {integrity: sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.11.2': + resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==} + '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} @@ -4965,6 +5039,11 @@ packages: react: '>=16.8.1' react-dom: '>=16.8.1' + react-icons@5.4.0: + resolution: {integrity: sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==} + peerDependencies: + react: '*' + react-markdown@9.0.1: resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==} peerDependencies: @@ -5559,6 +5638,12 @@ packages: resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} engines: {node: ^14.18.0 || >=16.0.0} + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + + tailwind-merge@2.6.0: + resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} + tar-fs@2.1.1: resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} @@ -7372,8 +7457,25 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@floating-ui/react@0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.8 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tabbable: 6.2.0 + '@floating-ui/utils@0.2.8': {} + '@headlessui/react@2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-aria/focus': 3.19.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-aria/interactions': 3.23.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': 3.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -7980,6 +8082,49 @@ snapshots: '@radix-ui/rect@1.1.0': {} + '@react-aria/focus@3.19.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-aria/interactions': 3.23.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-aria/utils': 3.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-types/shared': 3.27.0(react@18.3.1) + '@swc/helpers': 0.5.15 + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@react-aria/interactions@3.23.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-aria/ssr': 3.9.7(react@18.3.1) + '@react-aria/utils': 3.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-types/shared': 3.27.0(react@18.3.1) + '@swc/helpers': 0.5.15 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@react-aria/ssr@3.9.7(react@18.3.1)': + dependencies: + '@swc/helpers': 0.5.15 + react: 18.3.1 + + '@react-aria/utils@3.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-aria/ssr': 3.9.7(react@18.3.1) + '@react-stately/utils': 3.10.5(react@18.3.1) + '@react-types/shared': 3.27.0(react@18.3.1) + '@swc/helpers': 0.5.15 + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@react-stately/utils@3.10.5(react@18.3.1)': + dependencies: + '@swc/helpers': 0.5.15 + react: 18.3.1 + + '@react-types/shared@3.27.0(react@18.3.1)': + dependencies: + react: 18.3.1 + '@remix-run/cloudflare-pages@2.15.0(@cloudflare/workers-types@4.20241127.0)(typescript@5.7.2)': dependencies: '@cloudflare/workers-types': 4.20241127.0 @@ -8543,6 +8688,18 @@ snapshots: - supports-color - typescript + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tanstack/react-virtual@3.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.11.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.11.2': {} + '@types/acorn@4.0.6': dependencies: '@types/estree': 1.0.6 @@ -11823,6 +11980,10 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-icons@5.4.0(react@18.3.1): + dependencies: + react: 18.3.1 + react-markdown@9.0.1(@types/react@18.3.12)(react@18.3.1): dependencies: '@types/hast': 3.0.4 @@ -12456,6 +12617,10 @@ snapshots: '@pkgr/core': 0.1.1 tslib: 2.8.1 + tabbable@6.2.0: {} + + tailwind-merge@2.6.0: {} + tar-fs@2.1.1: dependencies: chownr: 1.1.4 diff --git a/uno.config.ts b/uno.config.ts index d8ac5a98..27743f45 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -1,23 +1,43 @@ import { globSync } from 'fast-glob'; import fs from 'node:fs/promises'; -import { basename } from 'node:path'; +import { basename, join } from 'node:path'; import { defineConfig, presetIcons, presetUno, transformerDirectives } from 'unocss'; +import type { IconifyJSON } from '@iconify/types'; -const iconPaths = globSync('./icons/*.svg'); +// Debug: Log the current working directory and icon paths +console.log('CWD:', process.cwd()); + +const iconPaths = globSync(join(process.cwd(), 'public/icons/*.svg')); +console.log('Found icons:', iconPaths); const collectionName = 'bolt'; -const customIconCollection = iconPaths.reduce( - (acc, iconPath) => { - const [iconName] = basename(iconPath).split('.'); +const customIconCollection = { + [collectionName]: iconPaths.reduce( + (acc, iconPath) => { + const [iconName] = basename(iconPath).split('.'); - acc[collectionName] ??= {}; - acc[collectionName][iconName] = async () => fs.readFile(iconPath, 'utf8'); + acc[iconName] = async () => { + try { + const content = await fs.readFile(iconPath, 'utf8'); + return content + .replace(/fill="[^"]*"/g, '') + .replace(/fill='[^']*'/g, '') + .replace(/width="[^"]*"/g, '') + .replace(/height="[^"]*"/g, '') + .replace(/viewBox="[^"]*"/g, 'viewBox="0 0 24 24"') + .replace(/]*)>/, ''); + } catch (error) { + console.error(`Error loading icon ${iconName}:`, error); + return ''; + } + }; - return acc; - }, - {} as Record Promise>>, -); + return acc; + }, + {} as Record Promise>, + ), +}; const BASE_COLORS = { white: '#FFFFFF', @@ -98,9 +118,7 @@ const COLOR_PRIMITIVES = { }; export default defineConfig({ - safelist: [ - ...Object.keys(customIconCollection[collectionName]||{}).map(x=>`i-bolt:${x}`) - ], + safelist: [...Object.keys(customIconCollection[collectionName] || {}).map((x) => `i-bolt:${x}`)], shortcuts: { 'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]', 'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier', @@ -242,9 +260,27 @@ export default defineConfig({ presetIcons({ warn: true, collections: { - ...customIconCollection, + bolt: customIconCollection.bolt, + ph: async () => { + const icons = await import('@iconify-json/ph/icons.json'); + return icons.default as IconifyJSON; + }, + }, + extraProperties: { + display: 'inline-block', + 'vertical-align': 'middle', + width: '24px', + height: '24px', + }, + customizations: { + customize(props) { + return { + ...props, + width: '24px', + height: '24px', + }; + }, }, - unit: 'em', }), ], });