Merge branch 'main' into main

This commit is contained in:
KevIsDev
2025-03-31 10:31:40 +01:00
committed by GitHub
44 changed files with 13636 additions and 5897 deletions

View File

@@ -97,6 +97,22 @@ AWS_BEDROCK_CONFIG=
# Include this environment variable if you want more logging for debugging locally # Include this environment variable if you want more logging for debugging locally
VITE_LOG_LEVEL=debug VITE_LOG_LEVEL=debug
# Get your GitHub Personal Access Token here -
# https://github.com/settings/tokens
# This token is used for:
# 1. Importing/cloning GitHub repositories without rate limiting
# 2. Accessing private repositories
# 3. Automatic GitHub authentication (no need to manually connect in the UI)
#
# For classic tokens, ensure it has these scopes: repo, read:org, read:user
# For fine-grained tokens, ensure it has Repository and Organization access
VITE_GITHUB_ACCESS_TOKEN=
# Specify the type of GitHub token you're using
# Can be 'classic' or 'fine-grained'
# Classic tokens are recommended for broader access
VITE_GITHUB_TOKEN_TYPE=classic
# Example Context Values for qwen2.5-coder:32b # Example Context Values for qwen2.5-coder:32b
# #
# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM # DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM

115
.env.production Normal file
View File

@@ -0,0 +1,115 @@
# Rename this file to .env once you have filled in the below environment variables!
# Get your GROQ API Key here -
# https://console.groq.com/keys
# You only need this environment variable set if you want to use Groq models
GROQ_API_KEY=
# Get your HuggingFace API Key here -
# https://huggingface.co/settings/tokens
# You only need this environment variable set if you want to use HuggingFace models
HuggingFace_API_KEY=
# Get your Open AI API Key by following these instructions -
# https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key
# You only need this environment variable set if you want to use GPT models
OPENAI_API_KEY=
# Get your Anthropic API Key in your account settings -
# https://console.anthropic.com/settings/keys
# You only need this environment variable set if you want to use Claude models
ANTHROPIC_API_KEY=
# Get your OpenRouter API Key in your account settings -
# https://openrouter.ai/settings/keys
# You only need this environment variable set if you want to use OpenRouter models
OPEN_ROUTER_API_KEY=
# Get your Google Generative AI API Key by following these instructions -
# https://console.cloud.google.com/apis/credentials
# You only need this environment variable set if you want to use Google Generative AI models
GOOGLE_GENERATIVE_AI_API_KEY=
# You only need this environment variable set if you want to use oLLAMA models
# DONT USE http://localhost:11434 due to IPV6 issues
# USE EXAMPLE http://127.0.0.1:11434
OLLAMA_API_BASE_URL=
# You only need this environment variable set if you want to use OpenAI Like models
OPENAI_LIKE_API_BASE_URL=
# You only need this environment variable set if you want to use Together AI models
TOGETHER_API_BASE_URL=
# You only need this environment variable set if you want to use DeepSeek models through their API
DEEPSEEK_API_KEY=
# Get your OpenAI Like API Key
OPENAI_LIKE_API_KEY=
# Get your Together API Key
TOGETHER_API_KEY=
# You only need this environment variable set if you want to use Hyperbolic models
HYPERBOLIC_API_KEY=
HYPERBOLIC_API_BASE_URL=
# Get your Mistral API Key by following these instructions -
# https://console.mistral.ai/api-keys/
# You only need this environment variable set if you want to use Mistral models
MISTRAL_API_KEY=
# Get the Cohere Api key by following these instructions -
# https://dashboard.cohere.com/api-keys
# You only need this environment variable set if you want to use Cohere models
COHERE_API_KEY=
# Get LMStudio Base URL from LM Studio Developer Console
# Make sure to enable CORS
# DONT USE http://localhost:1234 due to IPV6 issues
# Example: http://127.0.0.1:1234
LMSTUDIO_API_BASE_URL=
# Get your xAI API key
# https://x.ai/api
# You only need this environment variable set if you want to use xAI models
XAI_API_KEY=
# Get your Perplexity API Key here -
# https://www.perplexity.ai/settings/api
# You only need this environment variable set if you want to use Perplexity models
PERPLEXITY_API_KEY=
# Get your AWS configuration
# https://console.aws.amazon.com/iam/home
AWS_BEDROCK_CONFIG=
# Include this environment variable if you want more logging for debugging locally
VITE_LOG_LEVEL=
# Get your GitHub Personal Access Token here -
# https://github.com/settings/tokens
# This token is used for:
# 1. Importing/cloning GitHub repositories without rate limiting
# 2. Accessing private repositories
# 3. Automatic GitHub authentication (no need to manually connect in the UI)
#
# For classic tokens, ensure it has these scopes: repo, read:org, read:user
# For fine-grained tokens, ensure it has Repository and Organization access
VITE_GITHUB_ACCESS_TOKEN=
# Specify the type of GitHub token you're using
# Can be 'classic' or 'fine-grained'
# Classic tokens are recommended for broader access
VITE_GITHUB_TOKEN_TYPE=
# Netlify Authentication
VITE_NETLIFY_ACCESS_TOKEN=
# Example Context Values for qwen2.5-coder:32b
#
# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM
# DEFAULT_NUM_CTX=24576 # Consumes 32GB of VRAM
# DEFAULT_NUM_CTX=12288 # Consumes 26GB of VRAM
# DEFAULT_NUM_CTX=6144 # Consumes 24GB of VRAM
DEFAULT_NUM_CTX=

2
.gitignore vendored
View File

@@ -25,6 +25,7 @@ dist-ssr
/.history /.history
/.cache /.cache
/build /build
functions/build/
.env.local .env.local
.env .env
.dev.vars .dev.vars
@@ -44,3 +45,4 @@ changelogUI.md
docs/instructions/Roadmap.md docs/instructions/Roadmap.md
.cursorrules .cursorrules
*.md *.md
.qodo

View File

@@ -29,7 +29,7 @@ import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab'; import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab'; import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab'; 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 DebugTab from '~/components/@settings/tabs/debug/DebugTab';
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab'; import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
import UpdateTab from '~/components/@settings/tabs/update/UpdateTab'; 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]"> <div className="fixed inset-0 flex items-center justify-center z-[100]">
<RadixDialog.Overlay asChild> <RadixDialog.Overlay asChild>
<motion.div <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 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}

View File

@@ -0,0 +1,377 @@
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import { Button } from '~/components/ui/Button';
import { Badge } from '~/components/ui/Badge';
import { classNames } from '~/utils/classNames';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { CodeBracketIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
/**
* A diagnostics component to help troubleshoot connection issues
*/
export default function ConnectionDiagnostics() {
const [diagnosticResults, setDiagnosticResults] = useState<any>(null);
const [isRunning, setIsRunning] = useState(false);
const [showDetails, setShowDetails] = useState(false);
// Run diagnostics when requested
const runDiagnostics = async () => {
try {
setIsRunning(true);
setDiagnosticResults(null);
// Check browser-side storage
const localStorageChecks = {
githubConnection: localStorage.getItem('github_connection'),
netlifyConnection: localStorage.getItem('netlify_connection'),
};
// Get diagnostic data from server
const response = await fetch('/api/system/diagnostics');
if (!response.ok) {
throw new Error(`Diagnostics API error: ${response.status}`);
}
const serverDiagnostics = await response.json();
// Get GitHub token if available
const githubToken = localStorageChecks.githubConnection
? JSON.parse(localStorageChecks.githubConnection)?.token
: null;
const authHeaders = {
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
'Content-Type': 'application/json',
};
console.log('Testing GitHub endpoints with token:', githubToken ? 'present' : 'missing');
// Test GitHub API endpoints
const githubEndpoints = [
{ name: 'User', url: '/api/system/git-info?action=getUser' },
{ name: 'Repos', url: '/api/system/git-info?action=getRepos' },
{ name: 'Default', url: '/api/system/git-info' },
];
const githubResults = await Promise.all(
githubEndpoints.map(async (endpoint) => {
try {
const resp = await fetch(endpoint.url, {
headers: authHeaders,
});
return {
endpoint: endpoint.name,
status: resp.status,
ok: resp.ok,
};
} catch (error) {
return {
endpoint: endpoint.name,
error: error instanceof Error ? error.message : String(error),
ok: false,
};
}
}),
);
// Check if Netlify token works
let netlifyUserCheck = null;
const netlifyToken = localStorageChecks.netlifyConnection
? JSON.parse(localStorageChecks.netlifyConnection || '{"token":""}').token
: '';
if (netlifyToken) {
try {
const netlifyResp = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${netlifyToken}`,
},
});
netlifyUserCheck = {
status: netlifyResp.status,
ok: netlifyResp.ok,
};
} catch (error) {
netlifyUserCheck = {
error: error instanceof Error ? error.message : String(error),
ok: false,
};
}
}
// Compile results
const results = {
timestamp: new Date().toISOString(),
localStorage: {
hasGithubConnection: Boolean(localStorageChecks.githubConnection),
hasNetlifyConnection: Boolean(localStorageChecks.netlifyConnection),
githubConnectionParsed: localStorageChecks.githubConnection
? JSON.parse(localStorageChecks.githubConnection)
: null,
netlifyConnectionParsed: localStorageChecks.netlifyConnection
? JSON.parse(localStorageChecks.netlifyConnection)
: null,
},
apiEndpoints: {
github: githubResults,
netlify: netlifyUserCheck,
},
serverDiagnostics,
};
setDiagnosticResults(results);
// Display simple results
if (results.localStorage.hasGithubConnection && results.apiEndpoints.github.some((r: { ok: boolean }) => !r.ok)) {
toast.error('GitHub API connections are failing. Try reconnecting.');
}
if (results.localStorage.hasNetlifyConnection && netlifyUserCheck && !netlifyUserCheck.ok) {
toast.error('Netlify API connection is failing. Try reconnecting.');
}
if (!results.localStorage.hasGithubConnection && !results.localStorage.hasNetlifyConnection) {
toast.info('No connection data found in browser storage.');
}
} catch (error) {
console.error('Diagnostics error:', error);
toast.error('Error running diagnostics');
setDiagnosticResults({ error: error instanceof Error ? error.message : String(error) });
} finally {
setIsRunning(false);
}
};
// Helper to reset GitHub connection
const resetGitHubConnection = () => {
try {
localStorage.removeItem('github_connection');
document.cookie = 'githubToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
document.cookie = 'githubUsername=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
document.cookie = 'git:github.com=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
toast.success('GitHub connection data cleared. Please refresh the page and reconnect.');
} catch (error) {
console.error('Error clearing GitHub data:', error);
toast.error('Failed to clear GitHub connection data');
}
};
// Helper to reset Netlify connection
const resetNetlifyConnection = () => {
try {
localStorage.removeItem('netlify_connection');
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
toast.success('Netlify connection data cleared. Please refresh the page and reconnect.');
} catch (error) {
console.error('Error clearing Netlify data:', error);
toast.error('Failed to clear Netlify connection data');
}
};
return (
<div className="flex flex-col gap-6">
{/* Connection Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* GitHub Connection Card */}
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
<div className="flex items-center gap-2">
<div className="i-ph:github-logo text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
GitHub Connection
</div>
</div>
{diagnosticResults ? (
<>
<div className="flex items-center gap-2 mt-2">
<span
className={classNames(
'text-xl font-semibold',
diagnosticResults.localStorage.hasGithubConnection
? 'text-green-500 dark:text-green-400'
: 'text-red-500 dark:text-red-400',
)}
>
{diagnosticResults.localStorage.hasGithubConnection ? 'Connected' : 'Not Connected'}
</span>
</div>
{diagnosticResults.localStorage.hasGithubConnection && (
<>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
User: {diagnosticResults.localStorage.githubConnectionParsed?.user?.login || 'N/A'}
</div>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
API Status:{' '}
<Badge
variant={
diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok)
? 'default'
: 'destructive'
}
className="ml-1"
>
{diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok) ? 'OK' : 'Failed'}
</Badge>
</div>
</>
)}
{!diagnosticResults.localStorage.hasGithubConnection && (
<Button
onClick={() => window.location.reload()}
variant="outline"
size="sm"
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
Connect Now
</Button>
)}
</>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
Run diagnostics to check connection status
</div>
</div>
)}
</div>
{/* Netlify Connection Card */}
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
<div className="flex items-center gap-2">
<div className="i-si:netlify text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Netlify Connection
</div>
</div>
{diagnosticResults ? (
<>
<div className="flex items-center gap-2 mt-2">
<span
className={classNames(
'text-xl font-semibold',
diagnosticResults.localStorage.hasNetlifyConnection
? 'text-green-500 dark:text-green-400'
: 'text-red-500 dark:text-red-400',
)}
>
{diagnosticResults.localStorage.hasNetlifyConnection ? 'Connected' : 'Not Connected'}
</span>
</div>
{diagnosticResults.localStorage.hasNetlifyConnection && (
<>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
User:{' '}
{diagnosticResults.localStorage.netlifyConnectionParsed?.user?.full_name ||
diagnosticResults.localStorage.netlifyConnectionParsed?.user?.email ||
'N/A'}
</div>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
API Status:{' '}
<Badge
variant={diagnosticResults.apiEndpoints.netlify?.ok ? 'default' : 'destructive'}
className="ml-1"
>
{diagnosticResults.apiEndpoints.netlify?.ok ? 'OK' : 'Failed'}
</Badge>
</div>
</>
)}
{!diagnosticResults.localStorage.hasNetlifyConnection && (
<Button
onClick={() => window.location.reload()}
variant="outline"
size="sm"
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
Connect Now
</Button>
)}
</>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
Run diagnostics to check connection status
</div>
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-4">
<Button
onClick={runDiagnostics}
disabled={isRunning}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{isRunning ? (
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
) : (
<div className="i-ph:activity w-4 h-4" />
)}
{isRunning ? 'Running Diagnostics...' : 'Run Diagnostics'}
</Button>
<Button
onClick={resetGitHubConnection}
disabled={isRunning}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:github-logo w-4 h-4" />
Reset GitHub Connection
</Button>
<Button
onClick={resetNetlifyConnection}
disabled={isRunning}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-si:netlify w-4 h-4" />
Reset Netlify Connection
</Button>
</div>
{/* Details Panel */}
{diagnosticResults && (
<div className="mt-4">
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="w-full">
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<CodeBracketIcon className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Diagnostic Details
</span>
</div>
<ChevronDownIcon
className={classNames(
'w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary',
showDetails ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="p-4 mt-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor">
<pre className="text-xs overflow-auto max-h-96 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{JSON.stringify(diagnosticResults, null, 2)}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</div>
);
}

View File

@@ -1,41 +1,157 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import React, { Suspense } from 'react'; import React, { Suspense, useState } from 'react';
import { classNames } from '~/utils/classNames';
import ConnectionDiagnostics from './ConnectionDiagnostics';
import { Button } from '~/components/ui/Button';
import VercelConnection from './VercelConnection'; import VercelConnection from './VercelConnection';
// Use React.lazy for dynamic imports // Use React.lazy for dynamic imports
const GithubConnection = React.lazy(() => import('./GithubConnection')); const GitHubConnection = React.lazy(() => import('./GithubConnection'));
const NetlifyConnection = React.lazy(() => import('./NetlifyConnection')); const NetlifyConnection = React.lazy(() => import('./NetlifyConnection'));
// Loading fallback component // Loading fallback component
const LoadingFallback = () => ( const LoadingFallback = () => (
<div className="p-4 bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"> <div className="p-4 bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor">
<div className="flex items-center gap-2 text-bolt-elements-textSecondary"> <div className="flex items-center justify-center gap-2 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-5 h-5 animate-spin" /> <div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
<span>Loading connection...</span> <span>Loading connection...</span>
</div> </div>
</div> </div>
); );
export default function ConnectionsTab() { export default function ConnectionsTab() {
const [isEnvVarsExpanded, setIsEnvVarsExpanded] = useState(false);
const [showDiagnostics, setShowDiagnostics] = useState(false);
return ( return (
<div className="space-y-4"> <div className="space-y-6">
{/* Header */} {/* Header */}
<motion.div <motion.div
className="flex items-center gap-2 mb-2" className="flex items-center justify-between gap-2"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
> >
<div className="i-ph:plugs-connected w-5 h-5 text-purple-500" /> <div className="flex items-center gap-2">
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h2> <div className="i-ph:plugs-connected w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Connection Settings
</h2>
</div>
<Button
onClick={() => setShowDiagnostics(!showDiagnostics)}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{showDiagnostics ? (
<>
<div className="i-ph:eye-slash w-4 h-4" />
Hide Diagnostics
</>
) : (
<>
<div className="i-ph:wrench w-4 h-4" />
Troubleshoot Connections
</>
)}
</Button>
</motion.div> </motion.div>
<p className="text-sm text-bolt-elements-textSecondary mb-6"> <p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Manage your external service connections and integrations Manage your external service connections and integrations
</p> </p>
<div className="grid grid-cols-1 gap-4"> {/* Diagnostics Tool - Conditionally rendered */}
{showDiagnostics && <ConnectionDiagnostics />}
{/* Environment Variables Info - Collapsible */}
<motion.div
className="bg-bolt-elements-background dark:bg-bolt-elements-background rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="p-6">
<button
onClick={() => setIsEnvVarsExpanded(!isEnvVarsExpanded)}
className={classNames(
'w-full bg-transparent flex items-center justify-between',
'hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary',
'dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary',
'rounded-md p-2 -m-2 transition-colors',
)}
>
<div className="flex items-center gap-2">
<div className="i-ph:info w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Environment Variables
</h3>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary transition-transform',
isEnvVarsExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isEnvVarsExpanded && (
<div className="mt-4">
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
You can configure connections using environment variables in your{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
.env.local
</code>{' '}
file:
</p>
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 p-3 rounded-md text-xs font-mono overflow-x-auto">
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
# GitHub Authentication
</div>
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
VITE_GITHUB_ACCESS_TOKEN=your_token_here
</div>
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
# Optional: Specify token type (defaults to 'classic' if not specified)
</div>
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
VITE_GITHUB_TOKEN_TYPE=classic|fine-grained
</div>
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2">
# Netlify Authentication
</div>
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
VITE_NETLIFY_ACCESS_TOKEN=your_token_here
</div>
</div>
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary space-y-1">
<p>
<span className="font-medium">Token types:</span>
</p>
<ul className="list-disc list-inside pl-2 space-y-1">
<li>
<span className="font-medium">classic</span> - Personal Access Token with{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
repo, read:org, read:user
</code>{' '}
scopes
</li>
<li>
<span className="font-medium">fine-grained</span> - Fine-grained token with Repository and
Organization access
</li>
</ul>
<p className="mt-2">
When set, these variables will be used automatically without requiring manual connection.
</p>
</div>
</div>
)}
</div>
</motion.div>
<div className="grid grid-cols-1 gap-6">
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<GithubConnection /> <GitHubConnection />
</Suspense> </Suspense>
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<NetlifyConnection /> <NetlifyConnection />
@@ -44,6 +160,25 @@ export default function ConnectionsTab() {
<VercelConnection /> <VercelConnection />
</Suspense> </Suspense>
</div> </div>
{/* Additional help text */}
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 p-4 rounded-lg">
<p className="flex items-center gap-1 mb-2">
<span className="i-ph:lightbulb w-4 h-4 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
<span className="font-medium">Troubleshooting Tip:</span>
</p>
<p className="mb-2">
If you're having trouble with connections, try using the troubleshooting tool at the top of this page. It can
help diagnose and fix common connection issues.
</p>
<p>For persistent issues:</p>
<ol className="list-decimal list-inside pl-4 mt-1">
<li>Check your browser console for errors</li>
<li>Verify that your tokens have the correct permissions</li>
<li>Try clearing your browser cache and cookies</li>
<li>Ensure your browser allows third-party cookies if using integrations</li>
</ol>
</div>
</div> </div>
); );
} }

View File

@@ -1,263 +1,755 @@
import React, { useEffect, useState } from 'react'; import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { useStore } from '@nanostores/react';
import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify';
import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify';
import { import {
netlifyConnection, CloudIcon,
isConnecting, BuildingLibraryIcon,
isFetchingStats, ClockIcon,
updateNetlifyConnection, CodeBracketIcon,
fetchNetlifyStats, CheckCircleIcon,
} from '~/lib/stores/netlify'; XCircleIcon,
import type { NetlifyUser } from '~/types/netlify'; TrashIcon,
ArrowPathIcon,
LockClosedIcon,
LockOpenIcon,
RocketLaunchIcon,
} from '@heroicons/react/24/outline';
import { Button } from '~/components/ui/Button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { formatDistanceToNow } from 'date-fns';
import { Badge } from '~/components/ui/Badge';
// Add the Netlify logo SVG component at the top of the file
const NetlifyLogo = () => (
<svg viewBox="0 0 40 40" className="w-5 h-5">
<path
fill="currentColor"
d="M28.589 14.135l-.014-.006c-.008-.003-.016-.006-.023-.013a.11.11 0 0 1-.028-.093l.773-4.726 3.625 3.626-3.77 1.604a.083.083 0 0 1-.033.006h-.015c-.005-.003-.01-.007-.02-.017a1.716 1.716 0 0 0-.495-.381zm5.258-.288l3.876 3.876c.805.806 1.208 1.208 1.674 1.355a2 2 0 0 1 1.206 0c.466-.148.869-.55 1.674-1.356L8.73 28.73l2.349-3.643c.011-.018.022-.034.04-.047.025-.018.061-.01.091 0a2.434 2.434 0 0 0 1.638-.083c.027-.01.054-.017.075.002a.19.19 0 0 1 .028.032L21.95 38.05zM7.863 27.863L5.8 25.8l4.074-1.738a.084.084 0 0 1 .033-.007c.034 0 .054.034.072.065a2.91 2.91 0 0 0 .13.184l.013.016c.012.017.004.034-.008.05l-2.25 3.493zm-2.976-2.976l-2.61-2.61c-.444-.444-.766-.766-.99-1.043l7.936 1.646a.84.84 0 0 0 .03.005c.049.008.103.017.103.063 0 .05-.059.073-.109.092l-.023.01-4.337 1.837zM.831 19.892a2 2 0 0 1 .09-.495c.148-.466.55-.868 1.356-1.674l3.34-3.34a2175.525 2175.525 0 0 0 4.626 6.687c.027.036.057.076.026.106-.146.161-.292.337-.395.528a.16.16 0 0 1-.05.062c-.013.008-.027.005-.042.002H9.78L.831 19.892zm5.68-6.403l4.491-4.491c.422.185 1.958.834 3.332 1.414 1.04.44 1.988.84 2.286.97.03.012.057.024.07.054.008.018.004.041 0 .06a2.003 2.003 0 0 0 .523 1.828c.03.03 0 .073-.026.11l-.014.021-4.56 7.063c-.012.02-.023.037-.043.05-.024.015-.058.008-.086.001a2.274 2.274 0 0 0-.543-.074c-.164 0-.342.03-.522.063h-.001c-.02.003-.038.007-.054-.005a.21.21 0 0 1-.045-.051l-4.808-7.013zm5.398-5.398l5.814-5.814c.805-.805 1.208-1.208 1.674-1.355a2 2 0 0 1 1.206 0c.466.147.869.55 1.674 1.355l1.26 1.26-4.135 6.404a.155.155 0 0 1-.041.048c-.025.017-.06.01-.09 0a2.097 2.097 0 0 0-1.92.37c-.027.028-.067.012-.101-.003-.54-.235-4.74-2.01-5.341-2.265zm12.506-3.676l3.818 3.818-.92 5.698v.015a.135.135 0 0 1-.008.038c-.01.02-.03.024-.05.03a1.83 1.83 0 0 0-.548.273.154.154 0 0 0-.02.017c-.011.012-.022.023-.04.025a.114.114 0 0 1-.043-.007l-5.818-2.472-.011-.005c-.037-.015-.081-.033-.081-.071a2.198 2.198 0 0 0-.31-.915c-.028-.046-.059-.094-.035-.141l4.066-6.303zm-3.932 8.606l5.454 2.31c.03.014.063.027.076.058a.106.106 0 0 1 0 .057c-.016.08-.03.171-.03.263v.153c0 .038-.039.054-.075.069l-.011.004c-.864.369-12.13 5.173-12.147 5.173-.017 0-.035 0-.052-.017-.03-.03 0-.072.027-.11a.76.76 0 0 0 .014-.02l4.482-6.94.008-.012c.026-.042.056-.089.104-.089l.045.007c.102.014.192.027.283.027.68 0 1.31-.331 1.69-.897a.16.16 0 0 1 .034-.04c.027-.02.067-.01.098.004zm-6.246 9.185l12.28-5.237s.018 0 .035.017c.067.067.124.112.179.154l.027.017c.025.014.05.03.052.056 0 .01 0 .016-.002.025L25.756 23.7l-.004.026c-.007.05-.014.107-.061.107a1.729 1.729 0 0 0-1.373.847l-.005.008c-.014.023-.027.045-.05.057-.021.01-.048.006-.07.001l-9.793-2.02c-.01-.002-.152-.519-.163-.52z"
/>
</svg>
);
// Add new interface for site actions
interface SiteAction {
name: string;
icon: React.ComponentType<any>;
action: (siteId: string) => Promise<void>;
requiresConfirmation?: boolean;
variant?: 'default' | 'destructive' | 'outline';
}
export default function NetlifyConnection() { export default function NetlifyConnection() {
const connection = useStore(netlifyConnection); const connection = useStore(netlifyConnection);
const connecting = useStore(isConnecting); const [tokenInput, setTokenInput] = useState('');
const fetchingStats = useStore(isFetchingStats); const [fetchingStats, setFetchingStats] = useState(false);
const [isSitesExpanded, setIsSitesExpanded] = useState(false); const [sites, setSites] = useState<NetlifySite[]>([]);
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
useEffect(() => { const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
const fetchSites = async () => { const [deploymentCount, setDeploymentCount] = useState(0);
if (connection.user && connection.token) { const [lastUpdated, setLastUpdated] = useState('');
await fetchNetlifyStats(connection.token); const [isStatsOpen, setIsStatsOpen] = useState(false);
} const [activeSiteIndex, setActiveSiteIndex] = useState(0);
}; const [isActionLoading, setIsActionLoading] = useState(false);
fetchSites(); const [isConnecting, setIsConnecting] = useState(false);
}, [connection.user, connection.token]);
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
isConnecting.set(true);
// Add site actions
const siteActions: SiteAction[] = [
{
name: 'Clear Cache',
icon: ArrowPathIcon,
action: async (siteId: string) => {
try { try {
const response = await fetch('https://api.netlify.com/api/v1/user', { const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/cache`, {
method: 'POST',
headers: { headers: {
Authorization: `Bearer ${connection.token}`, Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
}, },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Invalid token or unauthorized'); throw new Error('Failed to clear cache');
}
toast.success('Site cache cleared successfully');
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to clear site cache: ${error}`);
}
},
},
{
name: 'Delete Site',
icon: TrashIcon,
action: async (siteId: string) => {
try {
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!response.ok) {
throw new Error('Failed to delete site');
}
toast.success('Site deleted successfully');
fetchNetlifyStats(connection.token);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to delete site: ${error}`);
}
},
requiresConfirmation: true,
variant: 'destructive',
},
];
// Add deploy management functions
const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => {
try {
setIsActionLoading(true);
const endpoint =
action === 'publish'
? `https://api.netlify.com/api/v1/sites/${siteId}/deploys/${deployId}/restore`
: `https://api.netlify.com/api/v1/deploys/${deployId}/${action}`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to ${action} deploy`);
}
toast.success(`Deploy ${action}ed successfully`);
fetchNetlifyStats(connection.token);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to ${action} deploy: ${error}`);
} finally {
setIsActionLoading(false);
}
};
useEffect(() => {
// Initialize connection with environment token if available
initializeNetlifyConnection();
}, []);
useEffect(() => {
// Check if we have a connection with a token but no stats
if (connection.user && connection.token && (!connection.stats || !connection.stats.sites)) {
fetchNetlifyStats(connection.token);
}
// Update local state from connection
if (connection.stats) {
setSites(connection.stats.sites || []);
setDeploys(connection.stats.deploys || []);
setBuilds(connection.stats.builds || []);
setDeploymentCount(connection.stats.deploys?.length || 0);
setLastUpdated(connection.stats.lastDeployTime || '');
}
}, [connection]);
const handleConnect = async () => {
if (!tokenInput) {
toast.error('Please enter a Netlify API token');
return;
}
setIsConnecting(true);
try {
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${tokenInput}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
} }
const userData = (await response.json()) as NetlifyUser; const userData = (await response.json()) as NetlifyUser;
// Update the connection store
updateNetlifyConnection({ updateNetlifyConnection({
user: userData, user: userData,
token: connection.token, token: tokenInput,
}); });
await fetchNetlifyStats(connection.token); toast.success('Connected to Netlify successfully');
toast.success('Successfully connected to Netlify');
// Fetch stats after successful connection
fetchNetlifyStats(tokenInput);
} catch (error) { } catch (error) {
console.error('Auth error:', error); console.error('Error connecting to Netlify:', error);
logStore.logError('Failed to authenticate with Netlify', { error }); toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`);
toast.error('Failed to connect to Netlify');
updateNetlifyConnection({ user: null, token: '' });
} finally { } finally {
isConnecting.set(false); setIsConnecting(false);
setTokenInput('');
} }
}; };
const handleDisconnect = () => { const handleDisconnect = () => {
// Clear from localStorage
localStorage.removeItem('netlify_connection');
// Remove cookies
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// Update the store
updateNetlifyConnection({ user: null, token: '' }); updateNetlifyConnection({ user: null, token: '' });
toast.success('Disconnected from Netlify'); toast.success('Disconnected from Netlify');
}; };
const fetchNetlifyStats = async (token: string) => {
setFetchingStats(true);
try {
// Fetch sites
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!sitesResponse.ok) {
throw new Error(`Failed to fetch sites: ${sitesResponse.statusText}`);
}
const sitesData = (await sitesResponse.json()) as NetlifySite[];
setSites(sitesData);
// Fetch recent deploys for the first site (if any)
let deploysData: NetlifyDeploy[] = [];
let buildsData: NetlifyBuild[] = [];
let lastDeployTime = '';
if (sitesData && sitesData.length > 0) {
const firstSite = sitesData[0];
// Fetch deploys
const deploysResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/deploys`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (deploysResponse.ok) {
deploysData = (await deploysResponse.json()) as NetlifyDeploy[];
setDeploys(deploysData);
setDeploymentCount(deploysData.length);
// Get the latest deploy time
if (deploysData.length > 0) {
lastDeployTime = deploysData[0].created_at;
setLastUpdated(lastDeployTime);
// Fetch builds for the site
const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/builds`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (buildsResponse.ok) {
buildsData = (await buildsResponse.json()) as NetlifyBuild[];
setBuilds(buildsData);
}
}
}
}
// Update the stats in the store
updateNetlifyConnection({
stats: {
sites: sitesData,
deploys: deploysData,
builds: buildsData,
lastDeployTime,
totalSites: sitesData.length,
},
});
toast.success('Netlify stats updated');
} catch (error) {
console.error('Error fetching Netlify stats:', error);
toast.error(`Failed to fetch Netlify stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setFetchingStats(false);
}
};
const renderStats = () => {
if (!connection.user || !connection.stats) {
return null;
}
return ( return (
<motion.div <div className="mt-6">
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]" <Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
initial={{ opacity: 0, y: 20 }} <CollapsibleTrigger asChild>
animate={{ opacity: 1, y: 0 }} <div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
transition={{ delay: 0.3 }} <div className="flex items-center gap-2">
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Netlify Stats
</span>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isStatsOpen ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
<div className="flex flex-wrap items-center gap-4">
<Badge
variant="outline"
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
<span>{connection.stats.totalSites} Sites</span>
</Badge>
<Badge
variant="outline"
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<RocketLaunchIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
<span>{deploymentCount} Deployments</span>
</Badge>
{lastUpdated && (
<Badge
variant="outline"
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<ClockIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
<span>Updated {formatDistanceToNow(new Date(lastUpdated))} ago</span>
</Badge>
)}
</div>
{sites.length > 0 && (
<div className="mt-4 space-y-4">
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Your Sites
</h4>
<Button
variant="outline"
size="sm"
onClick={() => fetchNetlifyStats(connection.token)}
disabled={fetchingStats}
className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10"
>
<ArrowPathIcon
className={classNames(
'h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent',
{ 'animate-spin': fetchingStats },
)}
/>
{fetchingStats ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
<div className="space-y-3">
{sites.map((site, index) => (
<div
key={site.id}
className={classNames(
'bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border rounded-lg p-4 transition-all',
activeSiteIndex === index
? 'border-bolt-elements-item-contentAccent bg-bolt-elements-item-backgroundActive/10'
: 'border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70',
)}
onClick={() => {
setActiveSiteIndex(index);
}}
> >
<div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <CloudIcon className="h-5 w-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
className="w-5 h-5" <span className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
height="24" {site.name}
width="24" </span>
crossOrigin="anonymous" </div>
src="https://cdn.simpleicons.org/netlify" <div className="flex items-center gap-2">
/> <Badge
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3> variant={site.published_deploy?.state === 'ready' ? 'default' : 'destructive'}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
{site.published_deploy?.state === 'ready' ? (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
) : (
<XCircleIcon className="h-4 w-4 text-red-500" />
)}
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{site.published_deploy?.state || 'Unknown'}
</span>
</Badge>
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<a
href={site.ssl_url || site.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm flex items-center gap-1 transition-colors text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:text-white dark:hover:text-bolt-elements-link-textHover"
onClick={(e) => e.stopPropagation()}
>
<CloudIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="underline decoration-1 underline-offset-2">
{site.ssl_url || site.url}
</span>
</a>
</div>
{activeSiteIndex === index && (
<>
<div className="mt-4 pt-3 border-t border-bolt-elements-borderColor">
<div className="flex items-center gap-2">
{siteActions.map((action) => (
<Button
key={action.name}
variant={action.variant || 'outline'}
size="sm"
onClick={async (e) => {
e.stopPropagation();
if (action.requiresConfirmation) {
if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
return;
}
}
setIsActionLoading(true);
await action.action(site.id);
setIsActionLoading(false);
}}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<action.icon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
{action.name}
</Button>
))}
</div>
</div>
{site.published_deploy && (
<div className="mt-3 text-sm">
<div className="flex items-center gap-1">
<ClockIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Published {formatDistanceToNow(new Date(site.published_deploy.published_at))} ago
</span>
</div>
{site.published_deploy.branch && (
<div className="flex items-center gap-1 mt-1">
<CodeBracketIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Branch: {site.published_deploy.branch}
</span>
</div>
)}
</div>
)}
</>
)}
</div>
))}
</div>
</div>
{activeSiteIndex !== -1 && deploys.length > 0 && (
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Recent Deployments
</h4>
</div>
<div className="space-y-2">
{deploys.map((deploy) => (
<div
key={deploy.id}
className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant={
deploy.state === 'ready'
? 'default'
: deploy.state === 'error'
? 'destructive'
: 'outline'
}
className="flex items-center gap-1"
>
{deploy.state === 'ready' ? (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
) : deploy.state === 'error' ? (
<XCircleIcon className="h-4 w-4 text-red-500" />
) : (
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
)}
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{deploy.state}
</span>
</Badge>
</div>
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{formatDistanceToNow(new Date(deploy.created_at))} ago
</span>
</div>
{deploy.branch && (
<div className="mt-2 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-1">
<CodeBracketIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Branch: {deploy.branch}
</span>
</div>
)}
{deploy.deploy_url && (
<div className="mt-2 text-xs">
<a
href={deploy.deploy_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 transition-colors text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:text-white dark:hover:text-bolt-elements-link-textHover"
onClick={(e) => e.stopPropagation()}
>
<CloudIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="underline decoration-1 underline-offset-2">{deploy.deploy_url}</span>
</a>
</div>
)}
<div className="flex items-center gap-2 mt-2">
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'publish')}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Publish
</Button>
{deploy.state === 'ready' ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'lock')}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<LockClosedIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Lock
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'unlock')}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<LockOpenIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Unlock
</Button>
)}
</div>
</div>
))}
</div>
</div>
)}
{activeSiteIndex !== -1 && builds.length > 0 && (
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
<CodeBracketIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Recent Builds
</h4>
</div>
<div className="space-y-2">
{builds.map((build) => (
<div
key={build.id}
className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant={
build.done && !build.error ? 'default' : build.error ? 'destructive' : 'outline'
}
className="flex items-center gap-1"
>
{build.done && !build.error ? (
<CheckCircleIcon className="h-4 w-4" />
) : build.error ? (
<XCircleIcon className="h-4 w-4" />
) : (
<CodeBracketIcon className="h-4 w-4" />
)}
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{build.done ? (build.error ? 'Failed' : 'Completed') : 'In Progress'}
</span>
</Badge>
</div>
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{formatDistanceToNow(new Date(build.created_at))} ago
</span>
</div>
{build.error && (
<div className="mt-2 text-xs text-bolt-elements-textDestructive dark:text-bolt-elements-textDestructive flex items-center gap-1">
<XCircleIcon className="h-3 w-3 text-bolt-elements-textDestructive dark:text-bolt-elements-textDestructive" />
Error: {build.error}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
return (
<div className="space-y-6 bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg">
<div className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="text-[#00AD9F]">
<NetlifyLogo />
</div>
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Netlify Connection</h2>
</div> </div>
</div> </div>
{!connection.user ? ( {!connection.user ? (
<div className="space-y-4"> <div className="mt-4">
<div> <label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label> API Token
</label>
<input <input
type="password" type="password"
value={connection.token} value={tokenInput}
onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })} onChange={(e) => setTokenInput(e.target.value)}
disabled={connecting} placeholder="Enter your Netlify API token"
placeholder="Enter your Netlify personal access token"
className={classNames( className={classNames(
'w-full px-3 py-2 rounded-lg text-sm', 'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]', 'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1',
'border border-[#E5E5E5] dark:border-[#333333]', 'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', 'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]', 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent',
'disabled:opacity-50',
)} )}
/> />
<div className="mt-2 text-sm text-bolt-elements-textSecondary"> <div className="mt-2 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
<a <a
href="https://app.netlify.com/user/applications#personal-access-tokens" href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[#00AD9F] hover:underline inline-flex items-center gap-1" className="text-bolt-elements-link-text dark:text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:hover:text-bolt-elements-link-textHover flex items-center gap-1"
> >
<div className="i-ph:key w-4 h-4" />
Get your token Get your token
<div className="i-ph:arrow-square-out w-4 h-4" /> <div className="i-ph:arrow-square-out w-3 h-3" />
</a> </a>
</div> </div>
</div> <div className="flex items-center justify-between mt-4">
<Button
<button
onClick={handleConnect} onClick={handleConnect}
disabled={connecting || !connection.token} disabled={isConnecting || !tokenInput}
className={classNames( variant="default"
'px-4 py-2 rounded-lg text-sm flex items-center gap-2', className="flex items-center gap-2"
'bg-[#00AD9F] text-white',
'hover:bg-[#00968A]',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
> >
{connecting ? ( {isConnecting ? (
<> <>
<div className="i-ph:spinner-gap animate-spin" /> <div className="i-ph:spinner-gap animate-spin w-4 h-4" />
Connecting... Connecting...
</> </>
) : ( ) : (
<> <>
<div className="i-ph:plug-charging w-4 h-4" /> <CloudIcon className="w-4 h-4" />
Connect Connect
</> </>
)} )}
</button> </Button>
</div>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="flex flex-col w-full gap-4 mt-4">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3"> <Button onClick={handleDisconnect} variant="destructive" size="sm" className="flex items-center gap-2">
<button <div className="i-ph:sign-out w-4 h-4" />
onClick={handleDisconnect}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-red-500 text-white',
'hover:bg-red-600',
)}
>
<div className="i-ph:plug w-4 h-4" />
Disconnect Disconnect
</button> </Button>
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4 text-green-500" /> <div className="flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4 text-green-500" />
<span className="text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Connected to Netlify Connected to Netlify
</span> </span>
</div> </div>
</div>
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
<img
src={connection.user.avatar_url}
referrerPolicy="no-referrer"
crossOrigin="anonymous"
alt={connection.user.full_name}
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
/>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p>
</div>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
onClick={() => window.open('https://app.netlify.com', '_blank', 'noopener,noreferrer')}
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:layout-dashboard w-4 h-4" />
Dashboard
</Button>
<Button
onClick={() => fetchNetlifyStats(connection.token)}
disabled={fetchingStats}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{fetchingStats ? ( {fetchingStats ? (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Fetching Netlify sites...
</div>
) : (
<div>
<button
onClick={() => setIsSitesExpanded(!isSitesExpanded)}
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
>
<div className="i-ph:buildings w-4 h-4" />
Your Sites ({connection.stats?.totalSites || 0})
<div
className={classNames(
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
isSitesExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isSitesExpanded && connection.stats?.sites?.length ? (
<div className="grid gap-3">
{connection.stats.sites.map((site) => (
<a
key={site.id}
href={site.admin_url}
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<div className="i-ph:globe w-4 h-4 text-[#00AD9F]" />
{site.name}
</h5>
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#00AD9F]"
>
{site.url}
</a>
{site.published_deploy && (
<> <>
<span></span> <div className="i-ph:spinner-gap w-4 h-4 animate-spin text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary" />
<span className="flex items-center gap-1"> <span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
<div className="i-ph:clock w-3 h-3" /> Refreshing...
{new Date(site.published_deploy.published_at).toLocaleDateString()} </span>
</>
) : (
<>
<ArrowPathIcon className="h-4 w-4 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary" />
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Refresh Stats
</span> </span>
</> </>
)} )}
</Button>
</div> </div>
</div> </div>
{site.build_settings?.provider && ( {renderStats()}
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
<span className="flex items-center gap-1">
<div className="i-ph:git-branch w-3 h-3" />
{site.build_settings.provider}
</span>
</div> </div>
)} )}
</div> </div>
</a>
))}
</div> </div>
) : isSitesExpanded ? (
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
No sites found in your Netlify account
</div>
) : null}
</div>
)}
</div>
)}
</div>
</motion.div>
); );
} }

View File

@@ -16,7 +16,7 @@ interface ConnectionFormProps {
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) { export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
// Check for saved token on mount // Check for saved token on mount
useEffect(() => { useEffect(() => {
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY); const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || Cookies.get('githubToken') || getLocalStorage(GITHUB_TOKEN_KEY);
if (savedToken && !authState.tokenInfo?.token) { if (savedToken && !authState.tokenInfo?.token) {
setAuthState((prev: GitHubAuthState) => ({ setAuthState((prev: GitHubAuthState) => ({
@@ -30,6 +30,9 @@ export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }
followers: 0, followers: 0,
}, },
})); }));
// Ensure the token is also saved with the correct key for API requests
Cookies.set('githubToken', savedToken);
} }
}, []); }, []);

View File

@@ -1,4 +1,4 @@
import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub'; import type { GitHubRepoInfo, GitHubContent, RepositoryStats, GitHubUserResponse } from '~/types/GitHub';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import * as Dialog from '@radix-ui/react-dialog'; import * as Dialog from '@radix-ui/react-dialog';
@@ -7,6 +7,7 @@ import { getLocalStorage } from '~/lib/persistence';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { formatSize } from '~/utils/formatSize'; import { formatSize } from '~/utils/formatSize';
import { Input } from '~/components/ui/Input'; import { Input } from '~/components/ui/Input';
import Cookies from 'js-cookie';
interface GitHubTreeResponse { interface GitHubTreeResponse {
tree: Array<{ tree: Array<{
@@ -122,6 +123,184 @@ function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDi
); );
} }
function GitHubAuthDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [token, setToken] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
return;
}
setIsSubmitting(true);
try {
const response = await fetch('https://api.github.com/user', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const userData = (await response.json()) as GitHubUserResponse;
// Save connection data
const connectionData = {
token,
tokenType,
user: {
login: userData.login,
avatar_url: userData.avatar_url,
name: userData.name || userData.login,
},
connected_at: new Date().toISOString(),
};
localStorage.setItem('github_connection', JSON.stringify(connectionData));
// Set cookies for API requests
Cookies.set('githubToken', token);
Cookies.set('githubUsername', userData.login);
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
toast.success(`Successfully connected as ${userData.login}`);
onClose();
} else {
if (response.status === 401) {
toast.error('Invalid GitHub token. Please check and try again.');
} else {
toast.error(`GitHub API error: ${response.status} ${response.statusText}`);
}
}
} catch (error) {
console.error('Error connecting to GitHub:', error);
toast.error('Failed to connect to GitHub. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<Dialog.Content className="bg-white dark:bg-[#1A1A1A] rounded-lg shadow-xl max-w-sm w-full mx-4 overflow-hidden">
<div className="p-4 space-y-3">
<h2 className="text-lg font-semibold text-[#111111] dark:text-white">Access Private Repositories</h2>
<p className="text-sm text-[#666666] dark:text-[#999999]">
To access private repositories, you need to connect your GitHub account by providing a personal access
token.
</p>
<div className="bg-[#F9F9F9] dark:bg-[#252525] p-4 rounded-lg space-y-3">
<h3 className="text-base font-medium text-[#111111] dark:text-white">Connect with GitHub Token</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="block text-sm text-[#666666] dark:text-[#999999] mb-1">
GitHub Personal Access Token
</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
className="w-full px-3 py-1.5 rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1A1A1A] text-[#111111] dark:text-white placeholder-[#999999] text-sm"
/>
<div className="mt-1 text-xs text-[#666666] dark:text-[#999999]">
Get your token at{' '}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:underline"
>
github.com/settings/tokens
</a>
</div>
</div>
<div className="space-y-1">
<label className="block text-sm text-[#666666] dark:text-[#999999]">Token Type</label>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input
type="radio"
checked={tokenType === 'classic'}
onChange={() => setTokenType('classic')}
className="w-3.5 h-3.5 accent-purple-500"
/>
<span className="text-sm text-[#111111] dark:text-white">Classic</span>
</label>
<label className="flex items-center gap-2">
<input
type="radio"
checked={tokenType === 'fine-grained'}
onChange={() => setTokenType('fine-grained')}
className="w-3.5 h-3.5 accent-purple-500"
/>
<span className="text-sm text-[#111111] dark:text-white">Fine-grained</span>
</label>
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isSubmitting ? 'Connecting...' : 'Connect to GitHub'}
</button>
</form>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg space-y-1.5">
<h3 className="text-sm text-amber-800 dark:text-amber-300 font-medium flex items-center gap-1.5">
<span className="i-ph:warning-circle w-4 h-4" />
Accessing Private Repositories
</h3>
<p className="text-xs text-amber-700 dark:text-amber-400">
Important things to know about accessing private repositories:
</p>
<ul className="list-disc pl-4 text-xs text-amber-700 dark:text-amber-400 space-y-0.5">
<li>You must be granted access to the repository by its owner</li>
<li>Your GitHub token must have the 'repo' scope</li>
<li>For organization repositories, you may need additional permissions</li>
<li>No token can give you access to repositories you don't have permission for</li>
</ul>
</div>
</div>
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-3 flex justify-end">
<Dialog.Close asChild>
<button
onClick={onClose}
className="px-4 py-1.5 bg-[#F5F5F5] hover:bg-[#E5E5E5] dark:bg-[#252525] dark:hover:bg-[#333333] rounded-lg text-[#111111] dark:text-white transition-colors text-sm"
>
Close
</button>
</Dialog.Close>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) { export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null); const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -133,13 +312,78 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]); const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
const [selectedBranch, setSelectedBranch] = useState(''); const [selectedBranch, setSelectedBranch] = useState('');
const [filters, setFilters] = useState<SearchFilters>({}); const [filters, setFilters] = useState<SearchFilters>({});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [stats, setStats] = useState<RepositoryStats | null>(null);
const [showStatsDialog, setShowStatsDialog] = useState(false); const [showStatsDialog, setShowStatsDialog] = useState(false);
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null); const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
const [pendingGitUrl, setPendingGitUrl] = useState<string>(''); const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
const [showAuthDialog, setShowAuthDialog] = useState(false);
// Fetch user's repositories when dialog opens // Handle GitHub auth dialog close and refresh repositories
const handleAuthDialogClose = () => {
setShowAuthDialog(false);
// If we're on the my-repos tab, refresh the repository list
if (activeTab === 'my-repos') {
fetchUserRepos();
}
};
// Initialize GitHub connection and fetch repositories
useEffect(() => {
const savedConnection = getLocalStorage('github_connection');
// If no connection exists but environment variables are set, create a connection
if (!savedConnection && import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
const token = import.meta.env.VITE_GITHUB_ACCESS_TOKEN;
const tokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE === 'fine-grained' ? 'fine-grained' : 'classic';
// Fetch GitHub user info to initialize the connection
fetch('https://api.github.com/user', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
})
.then((response) => {
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
return response.json();
})
.then((data: unknown) => {
const userData = data as GitHubUserResponse;
// Save connection to local storage
const newConnection = {
token,
tokenType,
user: {
login: userData.login,
avatar_url: userData.avatar_url,
name: userData.name || userData.login,
},
connected_at: new Date().toISOString(),
};
localStorage.setItem('github_connection', JSON.stringify(newConnection));
// Also save as cookies for API requests
Cookies.set('githubToken', token);
Cookies.set('githubUsername', userData.login);
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
// Refresh repositories after connection is established
if (isOpen && activeTab === 'my-repos') {
fetchUserRepos();
}
})
.catch((error) => {
console.error('Failed to initialize GitHub connection from environment variables:', error);
});
}
}, [isOpen]);
// Fetch repositories when dialog opens or tab changes
useEffect(() => { useEffect(() => {
if (isOpen && activeTab === 'my-repos') { if (isOpen && activeTab === 'my-repos') {
fetchUserRepos(); fetchUserRepos();
@@ -159,6 +403,7 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
try { try {
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', { const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
headers: { headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${connection.token}`, Authorization: `Bearer ${connection.token}`,
}, },
}); });
@@ -238,10 +483,15 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
setIsLoading(true); setIsLoading(true);
try { try {
const connection = getLocalStorage('github_connection');
const headers: HeadersInit = connection?.token
? {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${connection.token}`,
}
: {};
const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, { const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
headers: { headers,
Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`,
},
}); });
if (!response.ok) { if (!response.ok) {
@@ -285,34 +535,97 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => { const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
try { try {
const [owner, repo] = repoUrl // Extract branch from URL if present (format: url#branch)
let branch: string | null = null;
let cleanUrl = repoUrl;
if (repoUrl.includes('#')) {
const parts = repoUrl.split('#');
cleanUrl = parts[0];
branch = parts[1];
}
const [owner, repo] = cleanUrl
.replace(/\.git$/, '') .replace(/\.git$/, '')
.split('/') .split('/')
.slice(-2); .slice(-2);
// Try to get token from local storage first
const connection = getLocalStorage('github_connection'); const connection = getLocalStorage('github_connection');
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
const repoObjResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers,
});
const repoObjData = (await repoObjResponse.json()) as any;
if (!repoObjData.default_branch) { // If no connection in local storage, check environment variables
throw new Error('Failed to fetch repository branch'); let headers: HeadersInit = {};
if (connection?.token) {
headers = {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${connection.token}`,
};
} else if (import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
// Use token from environment variables
headers = {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${import.meta.env.VITE_GITHUB_ACCESS_TOKEN}`,
};
} }
const defaultBranch = repoObjData.default_branch; // First, get the repository info to determine the default branch
const repoInfoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers,
});
// Fetch repository tree if (!repoInfoResponse.ok) {
const treeResponse = await fetch( if (repoInfoResponse.status === 401 || repoInfoResponse.status === 403) {
throw new Error(
`Authentication failed (${repoInfoResponse.status}). Your GitHub token may be invalid or missing the required permissions.`,
);
} else if (repoInfoResponse.status === 404) {
throw new Error(
`Repository not found or is private (${repoInfoResponse.status}). To access private repositories, you need to connect your GitHub account or provide a valid token with appropriate permissions.`,
);
} else {
throw new Error(
`Failed to fetch repository information: ${repoInfoResponse.statusText} (${repoInfoResponse.status})`,
);
}
}
const repoInfo = (await repoInfoResponse.json()) as { default_branch: string };
let defaultBranch = repoInfo.default_branch || 'main';
// If a branch was specified in the URL, use that instead of the default
if (branch) {
defaultBranch = branch;
}
// Try to fetch the repository tree using the selected branch
let treeResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`, `https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
{ {
headers, headers,
}, },
); );
// If the selected branch doesn't work, try common branch names
if (!treeResponse.ok) { if (!treeResponse.ok) {
throw new Error('Failed to fetch repository structure'); // Try 'master' branch if default branch failed
treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/master?recursive=1`, {
headers,
});
// If master also fails, try 'main' branch
if (!treeResponse.ok) {
treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
headers,
});
}
// If all common branches fail, throw an error
if (!treeResponse.ok) {
throw new Error(
'Failed to fetch repository structure. Please check the repository URL and your access permissions.',
);
}
} }
const treeData = (await treeResponse.json()) as GitHubTreeResponse; const treeData = (await treeResponse.json()) as GitHubTreeResponse;
@@ -369,12 +682,27 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
hasDependencies, hasDependencies,
}; };
setStats(stats);
return stats; return stats;
} catch (error) { } catch (error) {
console.error('Error verifying repository:', error); console.error('Error verifying repository:', error);
toast.error('Failed to verify repository');
// Check if it's an authentication error and show the auth dialog
const errorMessage = error instanceof Error ? error.message : 'Failed to verify repository';
if (
errorMessage.includes('Authentication failed') ||
errorMessage.includes('may be private') ||
errorMessage.includes('Repository not found or is private') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('401') ||
errorMessage.includes('403') ||
errorMessage.includes('404') ||
errorMessage.includes('access permissions')
) {
setShowAuthDialog(true);
}
toast.error(errorMessage);
return null; return null;
} }
@@ -408,7 +736,36 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
setShowStatsDialog(true); setShowStatsDialog(true);
} catch (error) { } catch (error) {
console.error('Error preparing repository:', error); console.error('Error preparing repository:', error);
toast.error('Failed to prepare repository. Please try again.');
// Check if it's an authentication error
const errorMessage = error instanceof Error ? error.message : 'Failed to prepare repository. Please try again.';
// Show the GitHub auth dialog for any authentication or permission errors
if (
errorMessage.includes('Authentication failed') ||
errorMessage.includes('may be private') ||
errorMessage.includes('Repository not found or is private') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('401') ||
errorMessage.includes('403') ||
errorMessage.includes('404') ||
errorMessage.includes('access permissions')
) {
// Directly show the auth dialog instead of just showing a toast
setShowAuthDialog(true);
toast.error(
<div className="space-y-2">
<p>{errorMessage}</p>
<button onClick={() => setShowAuthDialog(true)} className="underline font-medium block text-purple-500">
Learn how to access private repositories
</button>
</div>,
{ autoClose: 10000 }, // Keep the toast visible longer
);
} else {
toast.error(errorMessage);
}
} }
}; };
@@ -441,6 +798,7 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
}; };
return ( return (
<>
<Dialog.Root <Dialog.Root
open={isOpen} open={isOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -471,6 +829,22 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
</Dialog.Close> </Dialog.Close>
</div> </div>
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="i-ph:info text-blue-500" />
<span className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
Need to access private repositories?
</span>
</div>
<button
onClick={() => setShowAuthDialog(true)}
className="px-3 py-1.5 rounded-lg bg-purple-500 hover:bg-purple-600 text-white text-sm transition-colors flex items-center gap-1.5"
>
<span className="i-ph:key" />
Connect GitHub Account
</button>
</div>
<div className="p-4"> <div className="p-4">
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}> <TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
@@ -490,17 +864,22 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
{activeTab === 'url' ? ( {activeTab === 'url' ? (
<div className="space-y-4"> <div className="space-y-4">
<Input <Input
placeholder="Enter repository URL" type="text"
placeholder="Enter GitHub repository URL"
value={customUrl} value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)} onChange={(e) => setCustomUrl(e.target.value)}
className={classNames('w-full', { className="w-full"
'border-red-500': false,
})}
/> />
<button <button
onClick={handleImport} onClick={handleImport}
disabled={!customUrl} disabled={!customUrl}
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2 justify-center" className={classNames(
'w-full h-10 px-4 py-2 rounded-lg text-white transition-all duration-200 flex items-center gap-2 justify-center',
customUrl
? 'bg-purple-500 hover:bg-purple-600'
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed',
)}
> >
Import Repository Import Repository
</button> </button>
@@ -607,16 +986,22 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
</div> </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
{/* GitHub Auth Dialog */}
<GitHubAuthDialog isOpen={showAuthDialog} onClose={handleAuthDialogClose} />
{/* Repository Stats Dialog */}
{currentStats && ( {currentStats && (
<StatsDialog <StatsDialog
isOpen={showStatsDialog} isOpen={showStatsDialog}
onClose={handleStatsConfirm} onClose={() => setShowStatsDialog(false)}
onConfirm={handleStatsConfirm} onConfirm={handleStatsConfirm}
stats={currentStats} stats={currentStats}
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024} isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
/> />
)} )}
</Dialog.Root> </Dialog.Root>
</>
); );
} }
@@ -670,7 +1055,7 @@ function RepositoryList({
function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) { function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
return ( return (
<div className="p-4 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] hover:border-purple-500/50 transition-colors"> <div className="p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 transition-colors">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="i-ph:git-repository text-bolt-elements-textTertiary" /> <span className="i-ph:git-repository text-bolt-elements-textTertiary" />

File diff suppressed because it is too large Load Diff

View 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>
);
}

View File

@@ -342,24 +342,86 @@ export default function DebugTab() {
try { try {
setLoading((prev) => ({ ...prev, systemInfo: true })); setLoading((prev) => ({ ...prev, systemInfo: true }));
// Get browser info // Get better OS detection
const ua = navigator.userAgent; const userAgent = navigator.userAgent;
const browserName = ua.includes('Firefox') let detectedOS = 'Unknown';
? 'Firefox' let detectedArch = 'unknown';
: ua.includes('Chrome')
? 'Chrome' // Improved OS detection
: ua.includes('Safari') if (userAgent.indexOf('Win') !== -1) {
? 'Safari' detectedOS = 'Windows';
: ua.includes('Edge') } else if (userAgent.indexOf('Mac') !== -1) {
? 'Edge' detectedOS = 'macOS';
: 'Unknown'; } else if (userAgent.indexOf('Linux') !== -1) {
const browserVersion = ua.match(/(Firefox|Chrome|Safari|Edge)\/([0-9.]+)/)?.[2] || 'Unknown'; 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 // Get performance metrics
const memory = (performance as any).memory || {}; const memory = (performance as any).memory || {};
const timing = performance.timing; const timing = performance.timing;
const navigation = performance.navigation; 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 // Get battery info
let batteryInfo; let batteryInfo;
@@ -405,9 +467,9 @@ export default function DebugTab() {
const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0; const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0;
const systemInfo: SystemInfo = { const systemInfo: SystemInfo = {
os: navigator.platform, os: detectedOS,
arch: navigator.userAgent.includes('x64') ? 'x64' : navigator.userAgent.includes('arm') ? 'arm' : 'unknown', arch: detectedArch,
platform: navigator.platform, platform: navigator.platform || 'unknown',
cpus: navigator.hardwareConcurrency + ' cores', cpus: navigator.hardwareConcurrency + ' cores',
memory: { memory: {
total: formatBytes(totalMemory), total: formatBytes(totalMemory),
@@ -423,7 +485,7 @@ export default function DebugTab() {
userAgent: navigator.userAgent, userAgent: navigator.userAgent,
cookiesEnabled: navigator.cookieEnabled, cookiesEnabled: navigator.cookieEnabled,
online: navigator.onLine, online: navigator.onLine,
platform: navigator.platform, platform: navigator.platform || 'unknown',
cores: navigator.hardwareConcurrency, cores: navigator.hardwareConcurrency,
}, },
screen: { screen: {
@@ -445,8 +507,8 @@ export default function DebugTab() {
usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0, usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0,
}, },
timing: { timing: {
loadTime: timing.loadEventEnd - timing.navigationStart, loadTime,
domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart, domReadyTime,
readyStart: timing.fetchStart - timing.navigationStart, readyStart: timing.fetchStart - timing.navigationStart,
redirectTime: timing.redirectEnd - timing.redirectStart, redirectTime: timing.redirectEnd - timing.redirectStart,
appcacheTime: timing.domainLookupStart - timing.fetchStart, 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 () => { const getWebAppInfo = async () => {
try { try {
setLoading((prev) => ({ ...prev, webAppInfo: true })); 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 = () => { const handleLogPerformance = () => {
try { try {
setLoading((prev) => ({ ...prev, performance: true })); setLoading((prev) => ({ ...prev, performance: true }));
@@ -1353,9 +1418,7 @@ export default function DebugTab() {
</div> </div>
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5"> <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" /> <div className="i-ph:code w-3.5 h-3.5 text-purple-500" />
DOM Ready: {systemInfo DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s
? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)
: '-'}s
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -159,10 +159,10 @@ ${escapeBoltTags(file.content)}
variant="outline" variant="outline"
size="lg" size="lg"
className={classNames( className={classNames(
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]', 'gap-2 bg-bolt-elements-background-depth-1',
'text-bolt-elements-textPrimary dark:text-white', 'text-bolt-elements-textPrimary',
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]', 'hover:bg-bolt-elements-background-depth-2',
'border-[#E5E5E5] dark:border-[#333333]', 'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
'h-10 px-4 py-2 min-w-[120px] justify-center', 'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out', 'transition-all duration-200 ease-in-out',
className, className,

View File

@@ -123,10 +123,10 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
variant="outline" variant="outline"
size="lg" size="lg"
className={classNames( className={classNames(
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]', 'gap-2 bg-bolt-elements-background-depth-1',
'text-bolt-elements-textPrimary dark:text-white', 'text-bolt-elements-textPrimary',
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]', 'hover:bg-bolt-elements-background-depth-2',
'border-[#E5E5E5] dark:border-[#333333]', 'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
'h-10 px-4 py-2 min-w-[120px] justify-center', 'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out', 'transition-all duration-200 ease-in-out',
className, className,

View File

@@ -67,10 +67,10 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
variant="outline" variant="outline"
size="lg" size="lg"
className={classNames( className={classNames(
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]', 'gap-2 bg-bolt-elements-background-depth-1',
'text-bolt-elements-textPrimary dark:text-white', 'text-bolt-elements-textPrimary',
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]', 'hover:bg-bolt-elements-background-depth-2',
'border-[#E5E5E5] dark:border-[#333333]', 'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
'h-10 px-4 py-2 min-w-[120px] justify-center', 'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out', 'transition-all duration-200 ease-in-out',
)} )}
@@ -81,10 +81,10 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
<ImportFolderButton <ImportFolderButton
importChat={importChat} importChat={importChat}
className={classNames( className={classNames(
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]', 'gap-2 bg-bolt-elements-background-depth-1',
'text-bolt-elements-textPrimary dark:text-white', 'text-bolt-elements-textPrimary',
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]', 'hover:bg-bolt-elements-background-depth-2',
'border border-[#E5E5E5] dark:border-[#333333]', 'border border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
'h-10 px-4 py-2 min-w-[120px] justify-center', 'h-10 px-4 py-2 min-w-[120px] justify-center',
'transition-all duration-200 ease-in-out rounded-lg', 'transition-all duration-200 ease-in-out rounded-lg',
)} )}

View File

@@ -10,7 +10,7 @@ const buttonVariants = cva(
default: 'bg-bolt-elements-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2', default: 'bg-bolt-elements-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
destructive: 'bg-red-500 text-white hover:bg-red-600', destructive: 'bg-red-500 text-white hover:bg-red-600',
outline: outline:
'border border-input bg-transparent hover:bg-bolt-elements-background-depth-2 hover:text-bolt-elements-textPrimary', 'border border-bolt-elements-borderColor bg-transparent hover:bg-bolt-elements-background-depth-2 hover:text-bolt-elements-textPrimary text-bolt-elements-textPrimary dark:border-bolt-elements-borderColorActive',
secondary: secondary:
'bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2', 'bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
ghost: 'hover:bg-bolt-elements-background-depth-1 hover:text-bolt-elements-textPrimary', ghost: 'hover:bg-bolt-elements-background-depth-1 hover:text-bolt-elements-textPrimary',

View 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 };

View File

@@ -1,9 +1,13 @@
import * as RadixDialog from '@radix-ui/react-dialog'; import * as RadixDialog from '@radix-ui/react-dialog';
import { motion, type Variants } from 'framer-motion'; 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 { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings'; import { cubicEasingFn } from '~/utils/easings';
import { IconButton } from './IconButton'; 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'; 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) => { export const DialogButton = memo(({ type, children, onClick, disabled }: DialogButtonProps) => {
return ( return (
<button <button
className={classNames('inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors', { className={classNames(
'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600': type === 'primary', 'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors',
'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 === 'primary'
type === 'secondary', ? 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600'
'bg-transparent text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10': type === 'danger', : 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} onClick={onClick}
disabled={disabled} disabled={disabled}
> >
@@ -34,7 +40,7 @@ export const DialogButton = memo(({ type, children, onClick, disabled }: DialogB
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => { export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
return ( return (
<RadixDialog.Title <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} {...props}
> >
{children} {children}
@@ -45,7 +51,7 @@ export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => { export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
return ( return (
<RadixDialog.Description <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} {...props}
> >
{children} {children}
@@ -99,11 +105,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
<RadixDialog.Portal> <RadixDialog.Portal>
<RadixDialog.Overlay asChild> <RadixDialog.Overlay asChild>
<motion.div <motion.div
className={classNames( className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
'fixed inset-0 z-[9999]',
'bg-[#FAFAFA]/80 dark:bg-[#0A0A0A]/80',
'backdrop-blur-[2px]',
)}
initial="closed" initial="closed"
animate="open" animate="open"
exit="closed" exit="closed"
@@ -114,11 +116,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
<RadixDialog.Content asChild> <RadixDialog.Content asChild>
<motion.div <motion.div
className={classNames( className={classNames(
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2', '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]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'rounded-lg shadow-lg',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'z-[9999] w-[520px]',
className, className,
)} )}
initial="closed" initial="closed"
@@ -132,7 +130,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
<RadixDialog.Close asChild onClick={onClose}> <RadixDialog.Close asChild onClick={onClose}>
<IconButton <IconButton
icon="i-ph:x" 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> </RadixDialog.Close>
)} )}
@@ -142,3 +140,310 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
</RadixDialog.Portal> </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>
);
}

View File

@@ -11,6 +11,7 @@ export const SettingsButton = memo(({ onClick }: SettingsButtonProps) => {
icon="i-ph:gear" icon="i-ph:gear"
size="xl" size="xl"
title="Settings" title="Settings"
data-testid="settings-button"
className="text-[#666] hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10 transition-colors" className="text-[#666] hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10 transition-colors"
/> />
); );

View File

@@ -12,13 +12,37 @@ interface WindowSize {
width: number; width: number;
height: number; height: number;
icon: string; icon: string;
hasFrame?: boolean;
frameType?: 'mobile' | 'tablet' | 'laptop' | 'desktop';
} }
const WINDOW_SIZES: WindowSize[] = [ const WINDOW_SIZES: WindowSize[] = [
{ name: 'Mobile', width: 375, height: 667, icon: 'i-ph:device-mobile' }, { name: 'iPhone SE', width: 375, height: 667, icon: 'i-ph:device-mobile', hasFrame: true, frameType: 'mobile' },
{ name: 'Tablet', width: 768, height: 1024, icon: 'i-ph:device-tablet' }, { name: 'iPhone 12/13', width: 390, height: 844, icon: 'i-ph:device-mobile', hasFrame: true, frameType: 'mobile' },
{ name: 'Laptop', width: 1366, height: 768, icon: 'i-ph:laptop' }, {
{ name: 'Desktop', width: 1920, height: 1080, icon: 'i-ph:monitor' }, name: 'iPhone 12/13 Pro Max',
width: 428,
height: 926,
icon: 'i-ph:device-mobile',
hasFrame: true,
frameType: 'mobile',
},
{ name: 'iPad Mini', width: 768, height: 1024, icon: 'i-ph:device-tablet', hasFrame: true, frameType: 'tablet' },
{ name: 'iPad Air', width: 820, height: 1180, icon: 'i-ph:device-tablet', hasFrame: true, frameType: 'tablet' },
{ name: 'iPad Pro 11"', width: 834, height: 1194, icon: 'i-ph:device-tablet', hasFrame: true, frameType: 'tablet' },
{
name: 'iPad Pro 12.9"',
width: 1024,
height: 1366,
icon: 'i-ph:device-tablet',
hasFrame: true,
frameType: 'tablet',
},
{ name: 'Small Laptop', width: 1280, height: 800, icon: 'i-ph:laptop', hasFrame: true, frameType: 'laptop' },
{ name: 'Laptop', width: 1366, height: 768, icon: 'i-ph:laptop', hasFrame: true, frameType: 'laptop' },
{ name: 'Large Laptop', width: 1440, height: 900, icon: 'i-ph:laptop', hasFrame: true, frameType: 'laptop' },
{ name: 'Desktop', width: 1920, height: 1080, icon: 'i-ph:monitor', hasFrame: true, frameType: 'desktop' },
{ name: '4K Display', width: 3840, height: 2160, icon: 'i-ph:monitor', hasFrame: true, frameType: 'desktop' },
]; ];
export const Preview = memo(() => { export const Preview = memo(() => {
@@ -43,6 +67,7 @@ export const Preview = memo(() => {
// Use percentage for width // Use percentage for width
const [widthPercent, setWidthPercent] = useState<number>(37.5); const [widthPercent, setWidthPercent] = useState<number>(37.5);
const [currentWidth, setCurrentWidth] = useState<number>(0);
const resizingState = useRef({ const resizingState = useRef({
isResizing: false, isResizing: false,
@@ -50,12 +75,17 @@ export const Preview = memo(() => {
startX: 0, startX: 0,
startWidthPercent: 37.5, startWidthPercent: 37.5,
windowWidth: window.innerWidth, windowWidth: window.innerWidth,
pointerId: null as number | null,
}); });
const SCALING_FACTOR = 2; // Reduce scaling factor to make resizing less sensitive
const SCALING_FACTOR = 1;
const [isWindowSizeDropdownOpen, setIsWindowSizeDropdownOpen] = useState(false); const [isWindowSizeDropdownOpen, setIsWindowSizeDropdownOpen] = useState(false);
const [selectedWindowSize, setSelectedWindowSize] = useState<WindowSize>(WINDOW_SIZES[0]); const [selectedWindowSize, setSelectedWindowSize] = useState<WindowSize>(WINDOW_SIZES[0]);
const [isLandscape, setIsLandscape] = useState(false);
const [showDeviceFrame, setShowDeviceFrame] = useState(true);
const [showDeviceFrameInPreview, setShowDeviceFrameInPreview] = useState(false);
useEffect(() => { useEffect(() => {
if (!activePreview) { if (!activePreview) {
@@ -133,68 +163,209 @@ export const Preview = memo(() => {
setIsDeviceModeOn((prev) => !prev); setIsDeviceModeOn((prev) => !prev);
}; };
const startResizing = (e: React.MouseEvent, side: ResizeSide) => { const startResizing = (e: React.PointerEvent, side: ResizeSide) => {
if (!isDeviceModeOn) { if (!isDeviceModeOn) {
return; return;
} }
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
document.body.style.cursor = 'ew-resize';
resizingState.current.isResizing = true; resizingState.current = {
resizingState.current.side = side; isResizing: true,
resizingState.current.startX = e.clientX; side,
resizingState.current.startWidthPercent = widthPercent; startX: e.clientX,
resizingState.current.windowWidth = window.innerWidth; startWidthPercent: widthPercent,
windowWidth: window.innerWidth,
document.addEventListener('mousemove', onMouseMove); pointerId: e.pointerId,
document.addEventListener('mouseup', onMouseUp); };
e.preventDefault();
}; };
const onMouseMove = (e: MouseEvent) => { const ResizeHandle = ({ side }: { side: ResizeSide }) => {
if (!resizingState.current.isResizing) { if (!side) {
return; return null;
} }
const dx = e.clientX - resizingState.current.startX; return (
const windowWidth = resizingState.current.windowWidth; <div
className={`resize-handle-${side}`}
const dxPercent = (dx / windowWidth) * 100 * SCALING_FACTOR; onPointerDown={(e) => startResizing(e, side)}
style={{
let newWidthPercent = resizingState.current.startWidthPercent; position: 'absolute',
top: 0,
if (resizingState.current.side === 'right') { ...(side === 'left' ? { left: 0, marginLeft: '-7px' } : { right: 0, marginRight: '-7px' }),
newWidthPercent = resizingState.current.startWidthPercent + dxPercent; width: '15px',
} else if (resizingState.current.side === 'left') { height: '100%',
newWidthPercent = resizingState.current.startWidthPercent - dxPercent; cursor: 'ew-resize',
background: 'var(--bolt-elements-background-depth-4, rgba(0,0,0,.3))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s',
userSelect: 'none',
touchAction: 'none',
zIndex: 10,
}}
onMouseOver={(e) =>
(e.currentTarget.style.background = 'var(--bolt-elements-background-depth-4, rgba(0,0,0,.3))')
} }
onMouseOut={(e) =>
newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90)); (e.currentTarget.style.background = 'var(--bolt-elements-background-depth-3, rgba(0,0,0,.15))')
}
setWidthPercent(newWidthPercent); title="Drag to resize width"
}; >
<GripIcon />
const onMouseUp = () => { </div>
resizingState.current.isResizing = false; );
resizingState.current.side = null;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.userSelect = '';
}; };
useEffect(() => {
// Skip if not in device mode
if (!isDeviceModeOn) {
return;
}
const handlePointerMove = (e: PointerEvent) => {
const state = resizingState.current;
if (!state.isResizing || e.pointerId !== state.pointerId) {
return;
}
const dx = e.clientX - state.startX;
const dxPercent = (dx / state.windowWidth) * 100 * SCALING_FACTOR;
let newWidthPercent = state.startWidthPercent;
if (state.side === 'right') {
newWidthPercent = state.startWidthPercent + dxPercent;
} else if (state.side === 'left') {
newWidthPercent = state.startWidthPercent - dxPercent;
}
// Limit width percentage between 10% and 90%
newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90));
// Force a synchronous update to ensure the UI reflects the change immediately
setWidthPercent(newWidthPercent);
// Calculate and update the actual pixel width
if (containerRef.current) {
const containerWidth = containerRef.current.clientWidth;
const newWidth = Math.round((containerWidth * newWidthPercent) / 100);
setCurrentWidth(newWidth);
// Apply the width directly to the container for immediate feedback
const previewContainer = containerRef.current.querySelector('div[style*="width"]');
if (previewContainer) {
(previewContainer as HTMLElement).style.width = `${newWidthPercent}%`;
}
}
};
const handlePointerUp = (e: PointerEvent) => {
const state = resizingState.current;
if (!state.isResizing || e.pointerId !== state.pointerId) {
return;
}
// Find all resize handles
const handles = document.querySelectorAll('.resize-handle-left, .resize-handle-right');
// Release pointer capture from any handle that has it
handles.forEach((handle) => {
if ((handle as HTMLElement).hasPointerCapture?.(e.pointerId)) {
(handle as HTMLElement).releasePointerCapture(e.pointerId);
}
});
// Reset state
resizingState.current = {
...resizingState.current,
isResizing: false,
side: null,
pointerId: null,
};
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
// Add event listeners
document.addEventListener('pointermove', handlePointerMove, { passive: false });
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('pointercancel', handlePointerUp);
// Define cleanup function
function cleanupResizeListeners() {
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.removeEventListener('pointercancel', handlePointerUp);
// Release any lingering pointer captures
if (resizingState.current.pointerId !== null) {
const handles = document.querySelectorAll('.resize-handle-left, .resize-handle-right');
handles.forEach((handle) => {
if ((handle as HTMLElement).hasPointerCapture?.(resizingState.current.pointerId!)) {
(handle as HTMLElement).releasePointerCapture(resizingState.current.pointerId!);
}
});
// Reset state
resizingState.current = {
...resizingState.current,
isResizing: false,
side: null,
pointerId: null,
};
document.body.style.userSelect = '';
document.body.style.cursor = '';
}
}
// Return the cleanup function
// eslint-disable-next-line consistent-return
return cleanupResizeListeners;
}, [isDeviceModeOn, SCALING_FACTOR]);
useEffect(() => { useEffect(() => {
const handleWindowResize = () => { const handleWindowResize = () => {
// Optional: Adjust widthPercent if necessary // Update the window width in the resizing state
resizingState.current.windowWidth = window.innerWidth;
// Update the current width in pixels
if (containerRef.current && isDeviceModeOn) {
const containerWidth = containerRef.current.clientWidth;
setCurrentWidth(Math.round((containerWidth * widthPercent) / 100));
}
}; };
window.addEventListener('resize', handleWindowResize); window.addEventListener('resize', handleWindowResize);
// Initial calculation of current width
if (containerRef.current && isDeviceModeOn) {
const containerWidth = containerRef.current.clientWidth;
setCurrentWidth(Math.round((containerWidth * widthPercent) / 100));
}
return () => { return () => {
window.removeEventListener('resize', handleWindowResize); window.removeEventListener('resize', handleWindowResize);
}; };
}, []); }, [isDeviceModeOn, widthPercent]);
// Update current width when device mode is toggled
useEffect(() => {
if (containerRef.current && isDeviceModeOn) {
const containerWidth = containerRef.current.clientWidth;
setCurrentWidth(Math.round((containerWidth * widthPercent) / 100));
}
}, [isDeviceModeOn]);
const GripIcon = () => ( const GripIcon = () => (
<div <div
@@ -208,7 +379,7 @@ export const Preview = memo(() => {
> >
<div <div
style={{ style={{
color: 'rgba(0,0,0,0.5)', color: 'var(--bolt-elements-textSecondary, rgba(0,0,0,0.5))',
fontSize: '10px', fontSize: '10px',
lineHeight: '5px', lineHeight: '5px',
userSelect: 'none', userSelect: 'none',
@@ -227,21 +398,234 @@ export const Preview = memo(() => {
if (match) { if (match) {
const previewId = match[1]; const previewId = match[1];
const previewUrl = `/webcontainer/preview/${previewId}`; const previewUrl = `/webcontainer/preview/${previewId}`;
// Adjust dimensions for landscape mode if applicable
let width = size.width;
let height = size.height;
if (isLandscape && (size.frameType === 'mobile' || size.frameType === 'tablet')) {
// Swap width and height for landscape mode
width = size.height;
height = size.width;
}
// Create a window with device frame if enabled
if (showDeviceFrame && size.hasFrame) {
// Calculate frame dimensions
const frameWidth = size.frameType === 'mobile' ? (isLandscape ? 120 : 40) : 60; // Width padding on each side
const frameHeight = size.frameType === 'mobile' ? (isLandscape ? 80 : 80) : isLandscape ? 60 : 100; // Height padding on top and bottom
// Create a window with the correct dimensions first
const newWindow = window.open(
'',
'_blank',
`width=${width + frameWidth},height=${height + frameHeight + 40},menubar=no,toolbar=no,location=no,status=no`,
);
if (!newWindow) {
console.error('Failed to open new window');
return;
}
// Create the HTML content for the frame
const frameColor = getFrameColor();
const frameRadius = size.frameType === 'mobile' ? '36px' : '20px';
const framePadding =
size.frameType === 'mobile'
? isLandscape
? '40px 60px'
: '40px 20px'
: isLandscape
? '30px 50px'
: '50px 30px';
// Position notch and home button based on orientation
const notchTop = isLandscape ? '50%' : '20px';
const notchLeft = isLandscape ? '30px' : '50%';
const notchTransform = isLandscape ? 'translateY(-50%)' : 'translateX(-50%)';
const notchWidth = isLandscape ? '8px' : size.frameType === 'mobile' ? '60px' : '80px';
const notchHeight = isLandscape ? (size.frameType === 'mobile' ? '60px' : '80px') : '8px';
const homeBottom = isLandscape ? '50%' : '15px';
const homeRight = isLandscape ? '30px' : '50%';
const homeTransform = isLandscape ? 'translateY(50%)' : 'translateX(50%)';
const homeWidth = isLandscape ? '4px' : '40px';
const homeHeight = isLandscape ? '40px' : '4px';
// Create HTML content for the wrapper page
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${size.name} Preview</title>
<style>
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f0f0f0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.device-container {
position: relative;
}
.device-name {
position: absolute;
top: -30px;
left: 0;
right: 0;
text-align: center;
font-size: 14px;
color: #333;
}
.device-frame {
position: relative;
border-radius: ${frameRadius};
background: ${frameColor};
padding: ${framePadding};
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
overflow: hidden;
}
/* Notch */
.device-frame:before {
content: '';
position: absolute;
top: ${notchTop};
left: ${notchLeft};
transform: ${notchTransform};
width: ${notchWidth};
height: ${notchHeight};
background: #333;
border-radius: 4px;
z-index: 2;
}
/* Home button */
.device-frame:after {
content: '';
position: absolute;
bottom: ${homeBottom};
right: ${homeRight};
transform: ${homeTransform};
width: ${homeWidth};
height: ${homeHeight};
background: #333;
border-radius: 50%;
z-index: 2;
}
iframe {
border: none;
width: ${width}px;
height: ${height}px;
background: white;
display: block;
}
</style>
</head>
<body>
<div class="device-container">
<div class="device-name">${size.name} ${isLandscape ? '(Landscape)' : '(Portrait)'}</div>
<div class="device-frame">
<iframe src="${previewUrl}" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin" allow="cross-origin-isolated"></iframe>
</div>
</div>
</body>
</html>
`;
// Write the HTML content to the new window
newWindow.document.open();
newWindow.document.write(htmlContent);
newWindow.document.close();
} else {
// Standard window without frame
const newWindow = window.open( const newWindow = window.open(
previewUrl, previewUrl,
'_blank', '_blank',
`noopener,noreferrer,width=${size.width},height=${size.height},menubar=no,toolbar=no,location=no,status=no`, `width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no`,
); );
if (newWindow) { if (newWindow) {
newWindow.focus(); newWindow.focus();
} }
}
} else { } else {
console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl); console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl);
} }
} }
}; };
// Function to get the correct frame padding based on orientation
const getFramePadding = useCallback(() => {
if (!selectedWindowSize) {
return '40px 20px';
}
const isMobile = selectedWindowSize.frameType === 'mobile';
if (isLandscape) {
// Increase horizontal padding in landscape mode to ensure full device frame is visible
return isMobile ? '40px 60px' : '30px 50px';
}
return isMobile ? '40px 20px' : '50px 30px';
}, [isLandscape, selectedWindowSize]);
// Function to get the scale factor for the device frame
const getDeviceScale = useCallback(() => {
// Always return 1 to ensure the device frame is shown at its exact size
return 1;
}, [isLandscape, selectedWindowSize, widthPercent]);
// Update the device scale when needed
useEffect(() => {
/*
* Intentionally disabled - we want to maintain scale of 1
* No dynamic scaling to ensure device frame matches external window exactly
*/
return () => {};
}, [isDeviceModeOn, showDeviceFrameInPreview, getDeviceScale, isLandscape, selectedWindowSize]);
// Function to get the frame color based on dark mode
const getFrameColor = useCallback(() => {
// Check if the document has a dark class or data-theme="dark"
const isDarkMode =
document.documentElement.classList.contains('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark' ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
// Return a darker color for light mode, lighter color for dark mode
return isDarkMode ? '#555' : '#111';
}, []);
// Effect to handle color scheme changes
useEffect(() => {
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleColorSchemeChange = () => {
// Force a re-render when color scheme changes
if (showDeviceFrameInPreview) {
setShowDeviceFrameInPreview(true);
}
};
darkModeMediaQuery.addEventListener('change', handleColorSchemeChange);
return () => {
darkModeMediaQuery.removeEventListener('change', handleColorSchemeChange);
};
}, [showDeviceFrameInPreview]);
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@@ -300,6 +684,21 @@ export const Preview = memo(() => {
title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'} title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
/> />
{isDeviceModeOn && (
<>
<IconButton
icon="i-ph:rotate-right"
onClick={() => setIsLandscape(!isLandscape)}
title={isLandscape ? 'Switch to Portrait' : 'Switch to Landscape'}
/>
<IconButton
icon={showDeviceFrameInPreview ? 'i-ph:device-mobile' : 'i-ph:device-mobile-slash'}
onClick={() => setShowDeviceFrameInPreview(!showDeviceFrameInPreview)}
title={showDeviceFrameInPreview ? 'Hide Device Frame' : 'Show Device Frame'}
/>
</>
)}
<IconButton <IconButton
icon="i-ph:layout-light" icon="i-ph:layout-light"
onClick={() => setIsPreviewOnly(!isPreviewOnly)} onClick={() => setIsPreviewOnly(!isPreviewOnly)}
@@ -328,7 +727,50 @@ export const Preview = memo(() => {
{isWindowSizeDropdownOpen && ( {isWindowSizeDropdownOpen && (
<> <>
<div className="fixed inset-0 z-50" onClick={() => setIsWindowSizeDropdownOpen(false)} /> <div className="fixed inset-0 z-50" onClick={() => setIsWindowSizeDropdownOpen(false)} />
<div className="absolute right-0 top-full mt-2 z-50 min-w-[240px] bg-white dark:bg-black rounded-xl shadow-2xl border border-[#E5E7EB] dark:border-[rgba(255,255,255,0.1)] overflow-hidden"> <div className="absolute right-0 top-full mt-2 z-50 min-w-[240px] max-h-[400px] overflow-y-auto bg-white dark:bg-black rounded-xl shadow-2xl border border-[#E5E7EB] dark:border-[rgba(255,255,255,0.1)] overflow-hidden">
<div className="p-3 border-b border-[#E5E7EB] dark:border-[rgba(255,255,255,0.1)]">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[#111827] dark:text-gray-300">Device Options</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-[#6B7280] dark:text-gray-400">Show Device Frame</span>
<button
className={`w-10 h-5 rounded-full transition-colors duration-200 ${
showDeviceFrame ? 'bg-[#6D28D9]' : 'bg-gray-300 dark:bg-gray-700'
} relative`}
onClick={(e) => {
e.stopPropagation();
setShowDeviceFrame(!showDeviceFrame);
}}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform duration-200 ${
showDeviceFrame ? 'transform translate-x-5' : ''
}`}
/>
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-[#6B7280] dark:text-gray-400">Landscape Mode</span>
<button
className={`w-10 h-5 rounded-full transition-colors duration-200 ${
isLandscape ? 'bg-[#6D28D9]' : 'bg-gray-300 dark:bg-gray-700'
} relative`}
onClick={(e) => {
e.stopPropagation();
setIsLandscape(!isLandscape);
}}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform duration-200 ${
isLandscape ? 'transform translate-x-5' : ''
}`}
/>
</button>
</div>
</div>
</div>
{WINDOW_SIZES.map((size) => ( {WINDOW_SIZES.map((size) => (
<button <button
key={size.name} key={size.name}
@@ -342,14 +784,34 @@ export const Preview = memo(() => {
<div <div
className={`${size.icon} w-5 h-5 text-[#6B7280] dark:text-gray-400 group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200`} className={`${size.icon} w-5 h-5 text-[#6B7280] dark:text-gray-400 group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200`}
/> />
<div className="flex flex-col"> <div className="flex-grow flex flex-col">
<span className="font-medium group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200"> <span className="font-medium group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200">
{size.name} {size.name}
</span> </span>
<span className="text-xs text-[#6B7280] dark:text-gray-400 group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200"> <span className="text-xs text-[#6B7280] dark:text-gray-400 group-hover:text-[#6D28D9] dark:group-hover:text-[#6D28D9] transition-colors duration-200">
{size.width} × {size.height} {isLandscape && (size.frameType === 'mobile' || size.frameType === 'tablet')
? `${size.height} × ${size.width}`
: `${size.width} × ${size.height}`}
{size.hasFrame && showDeviceFrame ? ' (with frame)' : ''}
</span> </span>
</div> </div>
{selectedWindowSize.name === size.name && (
<div className="text-[#6D28D9] dark:text-[#6D28D9]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
)}
</button> </button>
))} ))}
</div> </div>
@@ -362,16 +824,101 @@ export const Preview = memo(() => {
<div className="flex-1 border-t border-bolt-elements-borderColor flex justify-center items-center overflow-auto"> <div className="flex-1 border-t border-bolt-elements-borderColor flex justify-center items-center overflow-auto">
<div <div
style={{ style={{
width: isDeviceModeOn ? `${widthPercent}%` : '100%', width: isDeviceModeOn ? (showDeviceFrameInPreview ? '100%' : `${widthPercent}%`) : '100%',
height: '100%', height: '100%',
overflow: 'visible', overflow: 'auto',
background: 'var(--bolt-elements-background-depth-1)', background: 'var(--bolt-elements-background-depth-1)',
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}} }}
> >
{activePreview ? ( {activePreview ? (
<> <>
{isDeviceModeOn && showDeviceFrameInPreview ? (
<div
className="device-wrapper"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
padding: '0',
overflow: 'auto',
transition: 'all 0.3s ease',
position: 'relative',
}}
>
<div
className="device-frame-container"
style={{
position: 'relative',
borderRadius: selectedWindowSize.frameType === 'mobile' ? '36px' : '20px',
background: getFrameColor(),
padding: getFramePadding(),
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
overflow: 'hidden',
transform: 'scale(1)',
transformOrigin: 'center center',
transition: 'all 0.3s ease',
margin: '40px',
width: isLandscape
? `${selectedWindowSize.height + (selectedWindowSize.frameType === 'mobile' ? 120 : 60)}px`
: `${selectedWindowSize.width + (selectedWindowSize.frameType === 'mobile' ? 40 : 60)}px`,
height: isLandscape
? `${selectedWindowSize.width + (selectedWindowSize.frameType === 'mobile' ? 80 : 60)}px`
: `${selectedWindowSize.height + (selectedWindowSize.frameType === 'mobile' ? 80 : 100)}px`,
}}
>
{/* Notch - positioned based on orientation */}
<div
style={{
position: 'absolute',
top: isLandscape ? '50%' : '20px',
left: isLandscape ? '30px' : '50%',
transform: isLandscape ? 'translateY(-50%)' : 'translateX(-50%)',
width: isLandscape ? '8px' : selectedWindowSize.frameType === 'mobile' ? '60px' : '80px',
height: isLandscape ? (selectedWindowSize.frameType === 'mobile' ? '60px' : '80px') : '8px',
background: '#333',
borderRadius: '4px',
zIndex: 2,
}}
/>
{/* Home button - positioned based on orientation */}
<div
style={{
position: 'absolute',
bottom: isLandscape ? '50%' : '15px',
right: isLandscape ? '30px' : '50%',
transform: isLandscape ? 'translateY(50%)' : 'translateX(50%)',
width: isLandscape ? '4px' : '40px',
height: isLandscape ? '40px' : '4px',
background: '#333',
borderRadius: '50%',
zIndex: 2,
}}
/>
<iframe
ref={iframeRef}
title="preview"
style={{
border: 'none',
width: isLandscape ? `${selectedWindowSize.height}px` : `${selectedWindowSize.width}px`,
height: isLandscape ? `${selectedWindowSize.width}px` : `${selectedWindowSize.height}px`,
background: 'white',
display: 'block',
}}
src={iframeUrl}
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
allow="cross-origin-isolated"
/>
</div>
</div>
) : (
<iframe <iframe
ref={iframeRef} ref={iframeRef}
title="preview" title="preview"
@@ -380,6 +927,7 @@ export const Preview = memo(() => {
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
allow="cross-origin-isolated" allow="cross-origin-isolated"
/> />
)}
<ScreenshotSelector <ScreenshotSelector
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
setIsSelectionMode={setIsSelectionMode} setIsSelectionMode={setIsSelectionMode}
@@ -392,55 +940,30 @@ export const Preview = memo(() => {
</div> </div>
)} )}
{isDeviceModeOn && ( {isDeviceModeOn && !showDeviceFrameInPreview && (
<> <>
{/* Width indicator */}
<div <div
onMouseDown={(e) => startResizing(e, 'left')}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: '-25px',
left: 0, left: '50%',
width: '15px', transform: 'translateX(-50%)',
marginLeft: '-15px', background: 'var(--bolt-elements-background-depth-3, rgba(0,0,0,0.7))',
height: '100%', color: 'var(--bolt-elements-textPrimary, white)',
cursor: 'ew-resize', padding: '2px 8px',
background: 'rgba(255,255,255,.2)', borderRadius: '4px',
display: 'flex', fontSize: '12px',
alignItems: 'center', pointerEvents: 'none',
justifyContent: 'center', opacity: resizingState.current.isResizing ? 1 : 0,
transition: 'background 0.2s', transition: 'opacity 0.3s',
userSelect: 'none',
}} }}
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
title="Drag to resize width"
> >
<GripIcon /> {currentWidth}px
</div> </div>
<div <ResizeHandle side="left" />
onMouseDown={(e) => startResizing(e, 'right')} <ResizeHandle side="right" />
style={{
position: 'absolute',
top: 0,
right: 0,
width: '15px',
marginRight: '-15px',
height: '100%',
cursor: 'ew-resize',
background: 'rgba(255,255,255,.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s',
userSelect: 'none',
}}
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
title="Drag to resize width"
>
<GripIcon />
</div>
</> </>
)} )}
</div> </div>

View File

@@ -153,7 +153,40 @@ ${props.summary}
logger.info(`Sending llm call to ${provider.name} with model ${modelDetails.name}`); logger.info(`Sending llm call to ${provider.name} with model ${modelDetails.name}`);
// console.log(systemPrompt,processedMessages); // Store original messages for reference
const originalMessages = [...messages];
const hasMultimodalContent = originalMessages.some((msg) => Array.isArray(msg.content));
try {
if (hasMultimodalContent) {
/*
* For multimodal content, we need to preserve the original array structure
* but make sure the roles are valid and content items are properly formatted
*/
const multimodalMessages = originalMessages.map((msg) => ({
role: msg.role === 'system' || msg.role === 'user' || msg.role === 'assistant' ? msg.role : 'user',
content: Array.isArray(msg.content)
? msg.content.map((item) => {
// Ensure each content item has the correct format
if (typeof item === 'string') {
return { type: 'text', text: item };
}
if (item && typeof item === 'object') {
if (item.type === 'image' && item.image) {
return { type: 'image', image: item.image };
}
if (item.type === 'text') {
return { type: 'text', text: item.text || '' };
}
}
// Default fallback for unknown formats
return { type: 'text', text: String(item || '') };
})
: [{ type: 'text', text: typeof msg.content === 'string' ? msg.content : String(msg.content || '') }],
}));
return await _streamText({ return await _streamText({
model: provider.getModelInstance({ model: provider.getModelInstance({
@@ -164,7 +197,82 @@ ${props.summary}
}), }),
system: systemPrompt, system: systemPrompt,
maxTokens: dynamicMaxTokens, maxTokens: dynamicMaxTokens,
messages: convertToCoreMessages(processedMessages as any), messages: multimodalMessages as any,
...options, ...options,
}); });
} else {
// For non-multimodal content, we use the standard approach
const normalizedTextMessages = processedMessages.map((msg) => ({
role: msg.role === 'system' || msg.role === 'user' || msg.role === 'assistant' ? msg.role : 'user',
content: typeof msg.content === 'string' ? msg.content : String(msg.content || ''),
}));
return await _streamText({
model: provider.getModelInstance({
model: modelDetails.name,
serverEnv,
apiKeys,
providerSettings,
}),
system: systemPrompt,
maxTokens: dynamicMaxTokens,
messages: convertToCoreMessages(normalizedTextMessages),
...options,
});
}
} catch (error: any) {
// Special handling for format errors
if (error.message && error.message.includes('messages must be an array of CoreMessage or UIMessage')) {
logger.warn('Message format error detected, attempting recovery with explicit formatting...');
// Create properly formatted messages for all cases as a last resort
const fallbackMessages = processedMessages.map((msg) => {
// Determine text content with careful type handling
let textContent = '';
if (typeof msg.content === 'string') {
textContent = msg.content;
} else if (Array.isArray(msg.content)) {
// Handle array content safely
const contentArray = msg.content as any[];
textContent = contentArray
.map((contentItem) =>
typeof contentItem === 'string'
? contentItem
: contentItem?.text || contentItem?.image || String(contentItem || ''),
)
.join(' ');
} else {
textContent = String(msg.content || '');
}
return {
role: msg.role === 'system' || msg.role === 'user' || msg.role === 'assistant' ? msg.role : 'user',
content: [
{
type: 'text',
text: textContent,
},
],
};
});
// Try one more time with the fallback format
return await _streamText({
model: provider.getModelInstance({
model: modelDetails.name,
serverEnv,
apiKeys,
providerSettings,
}),
system: systemPrompt,
maxTokens: dynamicMaxTokens,
messages: fallbackMessages as any,
...options,
});
}
// If it's not a format error, re-throw the original error
throw error;
}
} }

View File

@@ -0,0 +1,966 @@
import { useState, useCallback } from 'react';
import { toast } from 'react-toastify';
import { ImportExportService } from '~/lib/services/importExportService';
import { useIndexedDB } from '~/lib/hooks/useIndexedDB';
import { generateId } from 'ai';
interface UseDataOperationsProps {
/**
* Callback to reload settings after import
*/
onReloadSettings?: () => void;
/**
* Callback to reload chats after import
*/
onReloadChats?: () => void;
/**
* Callback to reset settings to defaults
*/
onResetSettings?: () => void;
/**
* Callback to reset chats
*/
onResetChats?: () => void;
/**
* Custom database instance (optional)
*/
customDb?: IDBDatabase;
}
/**
* Hook for managing data operations in the DataTab
*/
export function useDataOperations({
onReloadSettings,
onReloadChats,
onResetSettings,
onResetChats,
customDb,
}: UseDataOperationsProps = {}) {
const { db: defaultDb } = useIndexedDB();
// Use the custom database if provided, otherwise use the default
const db = customDb || defaultDb;
const [isExporting, setIsExporting] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
const [progressMessage, setProgressMessage] = useState<string>('');
const [progressPercent, setProgressPercent] = useState<number>(0);
const [lastOperation, setLastOperation] = useState<{ type: string; data: any } | null>(null);
/**
* Show progress toast with percentage
*/
const showProgress = useCallback((message: string, percent: number) => {
setProgressMessage(message);
setProgressPercent(percent);
toast.loading(`${message} (${percent}%)`, { toastId: 'operation-progress' });
}, []);
/**
* Export all settings to a JSON file
*/
const handleExportSettings = useCallback(async () => {
setIsExporting(true);
setProgressPercent(0);
toast.loading('Preparing settings export...', { toastId: 'operation-progress' });
try {
// Step 1: Export settings
showProgress('Exporting settings', 25);
const settingsData = await ImportExportService.exportSettings();
// Step 2: Create blob
showProgress('Creating file', 50);
const blob = new Blob([JSON.stringify(settingsData, null, 2)], {
type: 'application/json',
});
// Step 3: Download file
showProgress('Downloading file', 75);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-settings.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Step 4: Complete
showProgress('Completing export', 100);
toast.success('Settings exported successfully', { toastId: 'operation-progress' });
// Save operation for potential undo
setLastOperation({ type: 'export-settings', data: settingsData });
} catch (error) {
console.error('Error exporting settings:', error);
toast.error(`Failed to export settings: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsExporting(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [showProgress]);
/**
* Export selected settings categories to a JSON file
* @param categoryIds Array of category IDs to export
*/
const handleExportSelectedSettings = useCallback(
async (categoryIds: string[]) => {
if (!categoryIds || categoryIds.length === 0) {
toast.error('No settings categories selected');
return;
}
setIsExporting(true);
setProgressPercent(0);
toast.loading(`Preparing export of ${categoryIds.length} settings categories...`, {
toastId: 'operation-progress',
});
try {
// Step 1: Export all settings
showProgress('Exporting settings', 20);
const allSettings = await ImportExportService.exportSettings();
// Step 2: Filter settings by category
showProgress('Filtering selected categories', 40);
const filteredSettings: Record<string, any> = {
exportDate: allSettings.exportDate,
};
// Add selected categories to filtered settings
categoryIds.forEach((category) => {
if (allSettings[category]) {
filteredSettings[category] = allSettings[category];
}
});
// Step 3: Create blob
showProgress('Creating file', 60);
const blob = new Blob([JSON.stringify(filteredSettings, null, 2)], {
type: 'application/json',
});
// Step 4: Download file
showProgress('Downloading file', 80);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-settings-selected.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Step 5: Complete
showProgress('Completing export', 100);
toast.success(`${categoryIds.length} settings categories exported successfully`, {
toastId: 'operation-progress',
});
// Save operation for potential undo
setLastOperation({ type: 'export-selected-settings', data: { categoryIds, settings: filteredSettings } });
} catch (error) {
console.error('Error exporting selected settings:', error);
toast.error(`Failed to export selected settings: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsExporting(false);
setProgressPercent(0);
setProgressMessage('');
}
},
[showProgress],
);
/**
* Export all chats to a JSON file
*/
const handleExportAllChats = useCallback(async () => {
if (!db) {
toast.error('Database not available');
return;
}
console.log('Export: Using database', {
name: db.name,
version: db.version,
objectStoreNames: Array.from(db.objectStoreNames),
});
setIsExporting(true);
setProgressPercent(0);
toast.loading('Preparing chats export...', { toastId: 'operation-progress' });
try {
// Step 1: Export chats
showProgress('Retrieving chats from database', 25);
console.log('Database details:', {
name: db.name,
version: db.version,
objectStoreNames: Array.from(db.objectStoreNames),
});
// Direct database query approach for more reliable access
const directChats = await new Promise<any[]>((resolve, reject) => {
try {
console.log(`Creating transaction on '${db.name}' database, objectStore 'chats'`);
const transaction = db.transaction(['chats'], 'readonly');
const store = transaction.objectStore('chats');
const request = store.getAll();
request.onsuccess = () => {
console.log(`Found ${request.result ? request.result.length : 0} chats directly from database`);
resolve(request.result || []);
};
request.onerror = () => {
console.error('Error querying chats store:', request.error);
reject(request.error);
};
} catch (err) {
console.error('Error creating transaction:', err);
reject(err);
}
});
// Export data with direct chats
const exportData = {
chats: directChats,
exportDate: new Date().toISOString(),
};
// Step 2: Create blob
showProgress('Creating file', 50);
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json',
});
// Step 3: Download file
showProgress('Downloading file', 75);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-chats.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Step 4: Complete
showProgress('Completing export', 100);
toast.success(`${exportData.chats.length} chats exported successfully`, { toastId: 'operation-progress' });
// Save operation for potential undo
setLastOperation({ type: 'export-all-chats', data: exportData });
} catch (error) {
console.error('Error exporting chats:', error);
toast.error(`Failed to export chats: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsExporting(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [db, showProgress]);
/**
* Export selected chats to a JSON file
* @param chatIds Array of chat IDs to export
*/
const handleExportSelectedChats = useCallback(
async (chatIds: string[]) => {
if (!db) {
toast.error('Database not available');
return;
}
if (!chatIds || chatIds.length === 0) {
toast.error('No chats selected');
return;
}
setIsExporting(true);
setProgressPercent(0);
toast.loading(`Preparing export of ${chatIds.length} chats...`, { toastId: 'operation-progress' });
try {
// Step 1: Directly query each selected chat from database
showProgress('Retrieving selected chats from database', 20);
console.log('Database details for selected chats:', {
name: db.name,
version: db.version,
objectStoreNames: Array.from(db.objectStoreNames),
});
// Query each chat directly from the database
const selectedChats = await Promise.all(
chatIds.map(async (chatId) => {
return new Promise<any>((resolve, reject) => {
try {
const transaction = db.transaction(['chats'], 'readonly');
const store = transaction.objectStore('chats');
const request = store.get(chatId);
request.onsuccess = () => {
if (request.result) {
console.log(`Found chat with ID ${chatId}:`, {
id: request.result.id,
messageCount: request.result.messages?.length || 0,
});
} else {
console.log(`Chat with ID ${chatId} not found`);
}
resolve(request.result || null);
};
request.onerror = () => {
console.error(`Error retrieving chat ${chatId}:`, request.error);
reject(request.error);
};
} catch (err) {
console.error(`Error in transaction for chat ${chatId}:`, err);
reject(err);
}
});
}),
);
// Filter out any null results (chats that weren't found)
const filteredChats = selectedChats.filter((chat) => chat !== null);
console.log(`Found ${filteredChats.length} selected chats out of ${chatIds.length} requested`);
// Step 2: Prepare export data
showProgress('Preparing export data', 40);
const exportData = {
chats: filteredChats,
exportDate: new Date().toISOString(),
};
// Step 3: Create blob
showProgress('Creating file', 60);
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json',
});
// Step 4: Download file
showProgress('Downloading file', 80);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-chats-selected.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Step 5: Complete
showProgress('Completing export', 100);
toast.success(`${filteredChats.length} chats exported successfully`, { toastId: 'operation-progress' });
// Save operation for potential undo
setLastOperation({ type: 'export-selected-chats', data: { chatIds, chats: filteredChats } });
} catch (error) {
console.error('Error exporting selected chats:', error);
toast.error(`Failed to export selected chats: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsExporting(false);
setProgressPercent(0);
setProgressMessage('');
}
},
[db, showProgress],
);
/**
* Import settings from a JSON file
* @param file The file to import
*/
const handleImportSettings = useCallback(
async (file: File) => {
setIsImporting(true);
setProgressPercent(0);
toast.loading(`Importing settings from ${file.name}...`, { toastId: 'operation-progress' });
try {
// Step 1: Read file
showProgress('Reading file', 20);
const fileContent = await file.text();
// Step 2: Parse JSON
showProgress('Parsing settings data', 40);
const importedData = JSON.parse(fileContent);
// Step 3: Validate data
showProgress('Validating settings data', 60);
// Save current settings for potential undo
const currentSettings = await ImportExportService.exportSettings();
setLastOperation({ type: 'import-settings', data: { previous: currentSettings } });
// Step 4: Import settings
showProgress('Applying settings', 80);
await ImportExportService.importSettings(importedData);
// Step 5: Complete
showProgress('Completing import', 100);
toast.success('Settings imported successfully', { toastId: 'operation-progress' });
if (onReloadSettings) {
onReloadSettings();
}
} catch (error) {
console.error('Error importing settings:', error);
toast.error(`Failed to import settings: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsImporting(false);
setProgressPercent(0);
setProgressMessage('');
}
},
[onReloadSettings, showProgress],
);
/**
* Import chats from a JSON file
* @param file The file to import
*/
const handleImportChats = useCallback(
async (file: File) => {
if (!db) {
toast.error('Database not available');
return;
}
setIsImporting(true);
setProgressPercent(0);
toast.loading(`Importing chats from ${file.name}...`, { toastId: 'operation-progress' });
try {
// Step 1: Read file
showProgress('Reading file', 20);
const fileContent = await file.text();
// Step 2: Parse JSON and validate structure
showProgress('Parsing chat data', 40);
const importedData = JSON.parse(fileContent);
if (!importedData.chats || !Array.isArray(importedData.chats)) {
throw new Error('Invalid chat data format: missing or invalid chats array');
}
// Step 3: Validate each chat object
showProgress('Validating chat data', 60);
const validatedChats = importedData.chats.map((chat: any) => {
if (!chat.id || !Array.isArray(chat.messages)) {
throw new Error('Invalid chat format: missing required fields');
}
// Ensure each message has required fields
const validatedMessages = chat.messages.map((msg: any) => {
if (!msg.role || !msg.content) {
throw new Error('Invalid message format: missing required fields');
}
return {
id: msg.id || generateId(),
role: msg.role,
content: msg.content,
name: msg.name,
function_call: msg.function_call,
timestamp: msg.timestamp || Date.now(),
};
});
return {
id: chat.id,
description: chat.description || '',
messages: validatedMessages,
timestamp: chat.timestamp || new Date().toISOString(),
urlId: chat.urlId || null,
metadata: chat.metadata || null,
};
});
// Step 4: Save current chats for potential undo
showProgress('Preparing database transaction', 70);
const currentChats = await ImportExportService.exportAllChats(db);
setLastOperation({ type: 'import-chats', data: { previous: currentChats } });
// Step 5: Import chats
showProgress(`Importing ${validatedChats.length} chats`, 80);
const transaction = db.transaction(['chats'], 'readwrite');
const store = transaction.objectStore('chats');
let processed = 0;
for (const chat of validatedChats) {
store.put(chat);
processed++;
if (processed % 5 === 0 || processed === validatedChats.length) {
showProgress(
`Imported ${processed} of ${validatedChats.length} chats`,
80 + (processed / validatedChats.length) * 20,
);
}
}
await new Promise((resolve, reject) => {
transaction.oncomplete = resolve;
transaction.onerror = reject;
});
// Step 6: Complete
showProgress('Completing import', 100);
toast.success(`${validatedChats.length} chats imported successfully`, { toastId: 'operation-progress' });
if (onReloadChats) {
onReloadChats();
}
} catch (error) {
console.error('Error importing chats:', error);
toast.error(`Failed to import chats: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsImporting(false);
setProgressPercent(0);
setProgressMessage('');
}
},
[db, onReloadChats, showProgress],
);
/**
* Import API keys from a JSON file
* @param file The file to import
*/
const handleImportAPIKeys = useCallback(
async (file: File) => {
setIsImporting(true);
setProgressPercent(0);
toast.loading(`Importing API keys from ${file.name}...`, { toastId: 'operation-progress' });
try {
// Step 1: Read file
showProgress('Reading file', 20);
const fileContent = await file.text();
// Step 2: Parse JSON
showProgress('Parsing API keys data', 40);
const importedData = JSON.parse(fileContent);
// Step 3: Validate data
showProgress('Validating API keys data', 60);
// Get current API keys from cookies for potential undo
const apiKeysStr = document.cookie.split(';').find((row) => row.trim().startsWith('apiKeys='));
const currentApiKeys = apiKeysStr ? JSON.parse(decodeURIComponent(apiKeysStr.split('=')[1])) : {};
setLastOperation({ type: 'import-api-keys', data: { previous: currentApiKeys } });
// Step 4: Import API keys
showProgress('Applying API keys', 80);
const newKeys = ImportExportService.importAPIKeys(importedData);
const apiKeysJson = JSON.stringify(newKeys);
document.cookie = `apiKeys=${apiKeysJson}; path=/; max-age=31536000`;
// Step 5: Complete
showProgress('Completing import', 100);
// Count how many keys were imported
const keyCount = Object.keys(newKeys).length;
const newKeyCount = Object.keys(newKeys).filter(
(key) => !currentApiKeys[key] || currentApiKeys[key] !== newKeys[key],
).length;
toast.success(
`${keyCount} API keys imported successfully (${newKeyCount} new/updated)\n` +
'Note: Keys are stored in browser cookies. For server-side usage, add them to your .env.local file.',
{ toastId: 'operation-progress', autoClose: 5000 },
);
if (onReloadSettings) {
onReloadSettings();
}
} catch (error) {
console.error('Error importing API keys:', error);
toast.error(`Failed to import API keys: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsImporting(false);
setProgressPercent(0);
setProgressMessage('');
}
},
[onReloadSettings, showProgress],
);
/**
* Reset all settings to default values
*/
const handleResetSettings = useCallback(async () => {
setIsResetting(true);
setProgressPercent(0);
toast.loading('Resetting settings...', { toastId: 'operation-progress' });
try {
if (db) {
// Step 1: Save current settings for potential undo
showProgress('Backing up current settings', 25);
const currentSettings = await ImportExportService.exportSettings();
setLastOperation({ type: 'reset-settings', data: { previous: currentSettings } });
// Step 2: Reset settings
showProgress('Resetting settings to defaults', 50);
await ImportExportService.resetAllSettings(db);
// Step 3: Complete
showProgress('Completing reset', 100);
toast.success('Settings reset successfully', { toastId: 'operation-progress' });
if (onResetSettings) {
onResetSettings();
}
} else {
toast.error('Database not available', { toastId: 'operation-progress' });
}
} catch (error) {
console.error('Error resetting settings:', error);
toast.error(`Failed to reset settings: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsResetting(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [db, onResetSettings, showProgress]);
/**
* Reset all chats
*/
const handleResetChats = useCallback(async () => {
if (!db) {
toast.error('Database not available');
return;
}
setIsResetting(true);
setProgressPercent(0);
toast.loading('Deleting all chats...', { toastId: 'operation-progress' });
try {
// Step 1: Save current chats for potential undo
showProgress('Backing up current chats', 25);
const currentChats = await ImportExportService.exportAllChats(db);
setLastOperation({ type: 'reset-chats', data: { previous: currentChats } });
// Step 2: Delete chats
showProgress('Deleting chats from database', 50);
await ImportExportService.deleteAllChats(db);
// Step 3: Complete
showProgress('Completing deletion', 100);
toast.success('All chats deleted successfully', { toastId: 'operation-progress' });
if (onResetChats) {
onResetChats();
}
} catch (error) {
console.error('Error resetting chats:', error);
toast.error(`Failed to delete chats: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsResetting(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [db, onResetChats, showProgress]);
/**
* Download API keys template
*/
const handleDownloadTemplate = useCallback(async () => {
setIsDownloadingTemplate(true);
setProgressPercent(0);
toast.loading('Preparing API keys template...', { toastId: 'operation-progress' });
try {
// Step 1: Create template
showProgress('Creating template', 50);
const templateData = ImportExportService.createAPIKeysTemplate();
// Step 2: Download file
showProgress('Downloading template', 75);
const blob = new Blob([JSON.stringify(templateData, 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);
// Step 3: Complete
showProgress('Completing download', 100);
toast.success('API keys template downloaded successfully', { toastId: 'operation-progress' });
} catch (error) {
console.error('Error downloading template:', error);
toast.error(`Failed to download template: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsDownloadingTemplate(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [showProgress]);
/**
* Export API keys to a JSON file
*/
const handleExportAPIKeys = useCallback(async () => {
setIsExporting(true);
setProgressPercent(0);
toast.loading('Preparing API keys export...', { toastId: 'operation-progress' });
try {
// Step 1: Get API keys from all sources
showProgress('Retrieving API keys', 25);
// Create a fetch request to get API keys from server
const response = await fetch('/api/export-api-keys');
if (!response.ok) {
throw new Error('Failed to retrieve API keys from server');
}
const apiKeys = await response.json();
// Step 2: Create blob
showProgress('Creating file', 50);
const blob = new Blob([JSON.stringify(apiKeys, null, 2)], {
type: 'application/json',
});
// Step 3: Download file
showProgress('Downloading file', 75);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bolt-api-keys.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Step 4: Complete
showProgress('Completing export', 100);
toast.success('API keys exported successfully', { toastId: 'operation-progress' });
// Save operation for potential undo
setLastOperation({ type: 'export-api-keys', data: apiKeys });
} catch (error) {
console.error('Error exporting API keys:', error);
toast.error(`Failed to export API keys: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
} finally {
setIsExporting(false);
setProgressPercent(0);
setProgressMessage('');
}
}, [showProgress]);
/**
* Undo the last operation if possible
*/
const handleUndo = useCallback(async () => {
if (!lastOperation || !db) {
toast.error('Nothing to undo');
return;
}
toast.loading('Attempting to undo last operation...', { toastId: 'operation-progress' });
try {
switch (lastOperation.type) {
case 'import-settings': {
// Restore previous settings
await ImportExportService.importSettings(lastOperation.data.previous);
toast.success('Settings import undone', { toastId: 'operation-progress' });
if (onReloadSettings) {
onReloadSettings();
}
break;
}
case 'import-chats': {
// Delete imported chats and restore previous state
await ImportExportService.deleteAllChats(db);
// Reimport previous chats
const transaction = db.transaction(['chats'], 'readwrite');
const store = transaction.objectStore('chats');
for (const chat of lastOperation.data.previous.chats) {
store.put(chat);
}
await new Promise((resolve, reject) => {
transaction.oncomplete = resolve;
transaction.onerror = reject;
});
toast.success('Chats import undone', { toastId: 'operation-progress' });
if (onReloadChats) {
onReloadChats();
}
break;
}
case 'reset-settings': {
// Restore previous settings
await ImportExportService.importSettings(lastOperation.data.previous);
toast.success('Settings reset undone', { toastId: 'operation-progress' });
if (onReloadSettings) {
onReloadSettings();
}
break;
}
case 'reset-chats': {
// Restore previous chats
const chatTransaction = db.transaction(['chats'], 'readwrite');
const chatStore = chatTransaction.objectStore('chats');
for (const chat of lastOperation.data.previous.chats) {
chatStore.put(chat);
}
await new Promise((resolve, reject) => {
chatTransaction.oncomplete = resolve;
chatTransaction.onerror = reject;
});
toast.success('Chats deletion undone', { toastId: 'operation-progress' });
if (onReloadChats) {
onReloadChats();
}
break;
}
case 'import-api-keys': {
// Restore previous API keys
const previousAPIKeys = lastOperation.data.previous;
const newKeys = ImportExportService.importAPIKeys(previousAPIKeys);
const apiKeysJson = JSON.stringify(newKeys);
document.cookie = `apiKeys=${apiKeysJson}; path=/; max-age=31536000`;
toast.success('API keys import undone', { toastId: 'operation-progress' });
if (onReloadSettings) {
onReloadSettings();
}
break;
}
default:
toast.error('Cannot undo this operation', { toastId: 'operation-progress' });
}
// Clear the last operation after undoing
setLastOperation(null);
} catch (error) {
console.error('Error undoing operation:', error);
toast.error(`Failed to undo: ${error instanceof Error ? error.message : 'Unknown error'}`, {
toastId: 'operation-progress',
});
}
}, [lastOperation, db, onReloadSettings, onReloadChats]);
return {
isExporting,
isImporting,
isResetting,
isDownloadingTemplate,
progressMessage,
progressPercent,
lastOperation,
handleExportSettings,
handleExportSelectedSettings,
handleExportAllChats,
handleExportSelectedChats,
handleImportSettings,
handleImportChats,
handleImportAPIKeys,
handleResetSettings,
handleResetChats,
handleDownloadTemplate,
handleExportAPIKeys,
handleUndo,
};
}

View File

@@ -50,6 +50,11 @@ export function useGit() {
fileData.current = {}; fileData.current = {};
/*
* Skip Git initialization for now - let isomorphic-git handle it
* This avoids potential issues with our manual initialization
*/
const headers: { const headers: {
[x: string]: string; [x: string]: string;
} = { } = {
@@ -72,18 +77,23 @@ export function useGit() {
singleBranch: true, singleBranch: true,
corsProxy: '/api/git-proxy', corsProxy: '/api/git-proxy',
headers, headers,
onProgress: (event) => {
console.log('Git clone progress:', event);
},
onAuth: (url) => { onAuth: (url) => {
let auth = lookupSavedPassword(url); let auth = lookupSavedPassword(url);
if (auth) { if (auth) {
console.log('Using saved authentication for', url);
return auth; return auth;
} }
console.log('Repository requires authentication:', url);
if (confirm('This repo is password protected. Ready to enter a username & password?')) { if (confirm('This repo is password protected. Ready to enter a username & password?')) {
auth = { auth = {
username: prompt('Enter username'), username: prompt('Enter username') || '',
password: prompt('Enter password'), password: prompt('Enter password') || '',
}; };
return auth; return auth;
} else { } else {
@@ -91,10 +101,12 @@ export function useGit() {
} }
}, },
onAuthFailure: (url, _auth) => { onAuthFailure: (url, _auth) => {
console.error(`Authentication failed for ${url}`);
toast.error(`Error Authenticating with ${url.split('/')[2]}`); toast.error(`Error Authenticating with ${url.split('/')[2]}`);
throw `Error Authenticating with ${url.split('/')[2]}`; throw `Error Authenticating with ${url.split('/')[2]}`;
}, },
onAuthSuccess: (url, auth) => { onAuthSuccess: (url, auth) => {
console.log(`Authentication successful for ${url}`);
saveGitAuth(url, auth); saveGitAuth(url, auth);
}, },
}); });
@@ -136,18 +148,26 @@ const getFs = (
throw error; throw error;
} }
}, },
writeFile: async (path: string, data: any, options: any) => { writeFile: async (path: string, data: any, options: any = {}) => {
const encoding = options.encoding;
const relativePath = pathUtils.relative(webcontainer.workdir, path); const relativePath = pathUtils.relative(webcontainer.workdir, path);
if (record.current) { if (record.current) {
record.current[relativePath] = { data, encoding }; record.current[relativePath] = { data, encoding: options?.encoding };
} }
try { try {
const result = await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding }); // Handle encoding properly based on data type
if (data instanceof Uint8Array) {
// For binary data, don't pass encoding
const result = await webcontainer.fs.writeFile(relativePath, data);
return result;
} else {
// For text data, use the encoding if provided
const encoding = options?.encoding || 'utf8';
const result = await webcontainer.fs.writeFile(relativePath, data, encoding);
return result; return result;
}
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -208,27 +228,40 @@ const getFs = (
stat: async (path: string) => { stat: async (path: string) => {
try { try {
const relativePath = pathUtils.relative(webcontainer.workdir, path); const relativePath = pathUtils.relative(webcontainer.workdir, path);
const resp = await webcontainer.fs.readdir(pathUtils.dirname(relativePath), { withFileTypes: true }); const dirPath = pathUtils.dirname(relativePath);
const name = pathUtils.basename(relativePath); const fileName = pathUtils.basename(relativePath);
const fileInfo = resp.find((x) => x.name == name);
if (!fileInfo) {
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
}
// Special handling for .git/index file
if (relativePath === '.git/index') {
return { return {
isFile: () => fileInfo.isFile(), isFile: () => true,
isDirectory: () => fileInfo.isDirectory(), isDirectory: () => false,
isSymbolicLink: () => false, isSymbolicLink: () => false,
size: 1, size: 12, // Size of our empty index
mode: 0o666, // Default permissions mode: 0o100644, // Regular file
mtimeMs: Date.now(), mtimeMs: Date.now(),
ctimeMs: Date.now(),
birthtimeMs: Date.now(),
atimeMs: Date.now(),
uid: 1000, uid: 1000,
gid: 1000, gid: 1000,
dev: 1,
ino: 1,
nlink: 1,
rdev: 0,
blksize: 4096,
blocks: 1,
mtime: new Date(),
ctime: new Date(),
birthtime: new Date(),
atime: new Date(),
}; };
} catch (error: any) { }
console.log(error?.message);
const resp = await webcontainer.fs.readdir(dirPath, { withFileTypes: true });
const fileInfo = resp.find((x) => x.name === fileName);
if (!fileInfo) {
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException; const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException;
err.code = 'ENOENT'; err.code = 'ENOENT';
err.errno = -2; err.errno = -2;
@@ -236,6 +269,40 @@ const getFs = (
err.path = path; err.path = path;
throw err; throw err;
} }
return {
isFile: () => fileInfo.isFile(),
isDirectory: () => fileInfo.isDirectory(),
isSymbolicLink: () => false,
size: fileInfo.isDirectory() ? 4096 : 1,
mode: fileInfo.isDirectory() ? 0o040755 : 0o100644, // Directory or regular file
mtimeMs: Date.now(),
ctimeMs: Date.now(),
birthtimeMs: Date.now(),
atimeMs: Date.now(),
uid: 1000,
gid: 1000,
dev: 1,
ino: 1,
nlink: 1,
rdev: 0,
blksize: 4096,
blocks: 8,
mtime: new Date(),
ctime: new Date(),
birthtime: new Date(),
atime: new Date(),
};
} catch (error: any) {
if (!error.code) {
error.code = 'ENOENT';
error.errno = -2;
error.syscall = 'stat';
error.path = path;
}
throw error;
}
}, },
lstat: async (path: string) => { lstat: async (path: string) => {
return await getFs(webcontainer, record).promises.stat(path); return await getFs(webcontainer, record).promises.stat(path);

View File

@@ -0,0 +1,58 @@
import { useState, useEffect } from 'react';
/**
* Hook to initialize and provide access to the IndexedDB database
*/
export function useIndexedDB() {
const [db, setDb] = useState<IDBDatabase | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const initDB = async () => {
try {
setIsLoading(true);
const request = indexedDB.open('boltDB', 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object stores if they don't exist
if (!db.objectStoreNames.contains('chats')) {
const chatStore = db.createObjectStore('chats', { keyPath: 'id' });
chatStore.createIndex('updatedAt', 'updatedAt', { unique: false });
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' });
}
};
request.onsuccess = (event) => {
const database = (event.target as IDBOpenDBRequest).result;
setDb(database);
setIsLoading(false);
};
request.onerror = (event) => {
setError(new Error(`Database error: ${(event.target as IDBOpenDBRequest).error?.message}`));
setIsLoading(false);
};
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error initializing database'));
setIsLoading(false);
}
};
initDB();
return () => {
if (db) {
db.close();
}
};
}, []);
return { db, isLoading, error };
}

View File

@@ -0,0 +1,140 @@
/**
* Functions for managing chat data in IndexedDB
*/
import type { Message } from 'ai';
import type { IChatMetadata } from './db'; // Import IChatMetadata
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
export interface Chat {
id: string;
description?: string;
messages: Message[];
timestamp: string;
urlId?: string;
metadata?: IChatMetadata;
}
/**
* Get all chats from the database
* @param db The IndexedDB database instance
* @returns A promise that resolves to an array of chats
*/
export async function getAllChats(db: IDBDatabase): Promise<Chat[]> {
console.log(`getAllChats: Using database '${db.name}', version ${db.version}`);
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(['chats'], 'readonly');
const store = transaction.objectStore('chats');
const request = store.getAll();
request.onsuccess = () => {
const result = request.result || [];
console.log(`getAllChats: Found ${result.length} chats in database '${db.name}'`);
resolve(result);
};
request.onerror = () => {
console.error(`getAllChats: Error querying database '${db.name}':`, request.error);
reject(request.error);
};
} catch (err) {
console.error(`getAllChats: Error creating transaction on database '${db.name}':`, err);
reject(err);
}
});
}
/**
* Get a chat by ID
* @param db The IndexedDB database instance
* @param id The ID of the chat to get
* @returns A promise that resolves to the chat or null if not found
*/
export async function getChatById(db: IDBDatabase, id: string): Promise<Chat | null> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['chats'], 'readonly');
const store = transaction.objectStore('chats');
const request = store.get(id);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(request.error);
};
});
}
/**
* Save a chat to the database
* @param db The IndexedDB database instance
* @param chat The chat to save
* @returns A promise that resolves when the chat is saved
*/
export async function saveChat(db: IDBDatabase, chat: Chat): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['chats'], 'readwrite');
const store = transaction.objectStore('chats');
const request = store.put(chat);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}
/**
* Delete a chat by ID
* @param db The IndexedDB database instance
* @param id The ID of the chat to delete
* @returns A promise that resolves when the chat is deleted
*/
export async function deleteChat(db: IDBDatabase, id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['chats'], 'readwrite');
const store = transaction.objectStore('chats');
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}
/**
* Delete all chats
* @param db The IndexedDB database instance
* @returns A promise that resolves when all chats are deleted
*/
export async function deleteAllChats(db: IDBDatabase): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['chats'], 'readwrite');
const store = transaction.objectStore('chats');
const request = store.clear();
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}

View File

@@ -0,0 +1,695 @@
import Cookies from 'js-cookie';
import { type Message } from 'ai';
import { getAllChats, deleteChat } from '~/lib/persistence/chats';
interface ExtendedMessage extends Message {
name?: string;
function_call?: any;
timestamp?: number;
}
/**
* Service for handling import and export operations of application data
*/
export class ImportExportService {
/**
* Export all chats to a JSON file
* @param db The IndexedDB database instance
* @returns A promise that resolves to the export data
*/
static async exportAllChats(db: IDBDatabase): Promise<{ chats: any[]; exportDate: string }> {
if (!db) {
throw new Error('Database not initialized');
}
try {
// Get all chats from the database using the getAllChats helper
const chats = await getAllChats(db);
// Validate and sanitize each chat before export
const sanitizedChats = chats.map((chat) => ({
id: chat.id,
description: chat.description || '',
messages: chat.messages.map((msg: ExtendedMessage) => ({
id: msg.id,
role: msg.role,
content: msg.content,
name: msg.name,
function_call: msg.function_call,
timestamp: msg.timestamp,
})),
timestamp: chat.timestamp,
urlId: chat.urlId || null,
metadata: chat.metadata || null,
}));
console.log(`Successfully prepared ${sanitizedChats.length} chats for export`);
return {
chats: sanitizedChats,
exportDate: new Date().toISOString(),
};
} catch (error) {
console.error('Error exporting chats:', error);
throw new Error(`Failed to export chats: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Export application settings to a JSON file
* @returns A promise that resolves to the settings data
*/
static async exportSettings(): Promise<any> {
try {
// Get all cookies
const allCookies = Cookies.get();
// Create a comprehensive settings object
return {
// Core settings
core: {
// User profile and main settings
bolt_user_profile: this._safeGetItem('bolt_user_profile'),
bolt_settings: this._safeGetItem('bolt_settings'),
bolt_profile: this._safeGetItem('bolt_profile'),
theme: this._safeGetItem('theme'),
},
// Provider settings (both local and cloud)
providers: {
// Provider configurations from localStorage
provider_settings: this._safeGetItem('provider_settings'),
// API keys from cookies
apiKeys: allCookies.apiKeys,
// Selected provider and model
selectedModel: allCookies.selectedModel,
selectedProvider: allCookies.selectedProvider,
// Provider-specific settings
providers: allCookies.providers,
},
// Feature settings
features: {
// Feature flags
viewed_features: this._safeGetItem('bolt_viewed_features'),
developer_mode: this._safeGetItem('bolt_developer_mode'),
// Context optimization
contextOptimizationEnabled: this._safeGetItem('contextOptimizationEnabled'),
// Auto-select template
autoSelectTemplate: this._safeGetItem('autoSelectTemplate'),
// Latest branch
isLatestBranch: this._safeGetItem('isLatestBranch'),
// Event logs
isEventLogsEnabled: this._safeGetItem('isEventLogsEnabled'),
// Energy saver settings
energySaverMode: this._safeGetItem('energySaverMode'),
autoEnergySaver: this._safeGetItem('autoEnergySaver'),
},
// UI configuration
ui: {
// Tab configuration
bolt_tab_configuration: this._safeGetItem('bolt_tab_configuration'),
tabConfiguration: allCookies.tabConfiguration,
// Prompt settings
promptId: this._safeGetItem('promptId'),
cachedPrompt: allCookies.cachedPrompt,
},
// Connections
connections: {
// Netlify connection
netlify_connection: this._safeGetItem('netlify_connection'),
// GitHub connections
...this._getGitHubConnections(allCookies),
},
// Debug and logs
debug: {
// Debug settings
isDebugEnabled: allCookies.isDebugEnabled,
acknowledged_debug_issues: this._safeGetItem('bolt_acknowledged_debug_issues'),
acknowledged_connection_issue: this._safeGetItem('bolt_acknowledged_connection_issue'),
// Error logs
error_logs: this._safeGetItem('error_logs'),
bolt_read_logs: this._safeGetItem('bolt_read_logs'),
// Event logs
eventLogs: allCookies.eventLogs,
},
// Update settings
updates: {
update_settings: this._safeGetItem('update_settings'),
last_acknowledged_update: this._safeGetItem('bolt_last_acknowledged_version'),
},
// Chat snapshots (for chat history)
chatSnapshots: this._getChatSnapshots(),
// Raw data (for debugging and complete backup)
_raw: {
localStorage: this._getAllLocalStorage(),
cookies: allCookies,
},
// Export metadata
_meta: {
exportDate: new Date().toISOString(),
version: '2.0',
appVersion: process.env.NEXT_PUBLIC_VERSION || 'unknown',
},
};
} catch (error) {
console.error('Error exporting settings:', error);
throw error;
}
}
/**
* Import settings from a JSON file
* @param importedData The imported data
*/
static async importSettings(importedData: any): Promise<void> {
// Check if this is the new comprehensive format (v2.0)
const isNewFormat = importedData._meta?.version === '2.0';
if (isNewFormat) {
// Import using the new comprehensive format
await this._importComprehensiveFormat(importedData);
} else {
// Try to handle older formats
await this._importLegacyFormat(importedData);
}
}
/**
* Import API keys from a JSON file
* @param keys The API keys to import
*/
static importAPIKeys(keys: Record<string, any>): Record<string, string> {
// Get existing keys from cookies
const existingKeys = (() => {
const storedApiKeys = Cookies.get('apiKeys');
return storedApiKeys ? JSON.parse(storedApiKeys) : {};
})();
// Validate and save each key
const newKeys = { ...existingKeys };
Object.entries(keys).forEach(([key, value]) => {
// Skip comment fields
if (key.startsWith('_')) {
return;
}
// Skip base URL fields (they should be set in .env.local)
if (key.includes('_API_BASE_URL')) {
return;
}
if (typeof value !== 'string') {
throw new Error(`Invalid value for key: ${key}`);
}
// Handle both old and new template formats
let normalizedKey = key;
// Check if this is the old format (e.g., "Anthropic_API_KEY")
if (key.includes('_API_KEY')) {
// Extract the provider name from the old format
normalizedKey = key.replace('_API_KEY', '');
}
/*
* Only add non-empty keys
* Use the normalized key in the correct format
* (e.g., "OpenAI", "Google", "Anthropic")
*/
if (value) {
newKeys[normalizedKey] = value;
}
});
return newKeys;
}
/**
* Create an API keys template
* @returns The API keys template
*/
static createAPIKeysTemplate(): Record<string, any> {
/*
* Create a template with provider names as keys
* This matches how the application stores API keys in cookies
*/
const template = {
Anthropic: '',
OpenAI: '',
Google: '',
Groq: '',
HuggingFace: '',
OpenRouter: '',
Deepseek: '',
Mistral: '',
OpenAILike: '',
Together: '',
xAI: '',
Perplexity: '',
Cohere: '',
AzureOpenAI: '',
};
// Add a comment to explain the format
return {
_comment:
"Fill in your API keys for each provider. Keys will be stored with the provider name (e.g., 'OpenAI'). The application also supports the older format with keys like 'OpenAI_API_KEY' for backward compatibility.",
...template,
};
}
/**
* Reset all settings to default values
* @param db The IndexedDB database instance
*/
static async resetAllSettings(db: IDBDatabase): Promise<void> {
// 1. Clear all localStorage items related to application settings
const localStorageKeysToPreserve: string[] = ['debug_mode']; // Keys to preserve if needed
// Get all localStorage keys
const allLocalStorageKeys = Object.keys(localStorage);
// Clear all localStorage items except those to preserve
allLocalStorageKeys.forEach((key) => {
if (!localStorageKeysToPreserve.includes(key)) {
try {
localStorage.removeItem(key);
} catch (err) {
console.error(`Error removing localStorage item ${key}:`, err);
}
}
});
// 2. Clear all cookies related to application settings
const cookiesToPreserve: string[] = []; // Cookies to preserve if needed
// Get all cookies
const allCookies = Cookies.get();
const cookieKeys = Object.keys(allCookies);
// Clear all cookies except those to preserve
cookieKeys.forEach((key) => {
if (!cookiesToPreserve.includes(key)) {
try {
Cookies.remove(key);
} catch (err) {
console.error(`Error removing cookie ${key}:`, err);
}
}
});
// 3. Clear all data from IndexedDB
if (!db) {
console.warn('Database not initialized, skipping IndexedDB reset');
} else {
// Get all chats and delete them
const chats = await getAllChats(db);
const deletePromises = chats.map((chat) => deleteChat(db, chat.id));
await Promise.all(deletePromises);
}
// 4. Clear any chat snapshots
const snapshotKeys = Object.keys(localStorage).filter((key) => key.startsWith('snapshot:'));
snapshotKeys.forEach((key) => {
try {
localStorage.removeItem(key);
} catch (err) {
console.error(`Error removing snapshot ${key}:`, err);
}
});
}
/**
* Delete all chats from the database
* @param db The IndexedDB database instance
*/
static async deleteAllChats(db: IDBDatabase): Promise<void> {
// Clear chat history from localStorage
localStorage.removeItem('bolt_chat_history');
// Clear chats from IndexedDB
if (!db) {
throw new Error('Database not initialized');
}
// Get all chats and delete them one by one
const chats = await getAllChats(db);
const deletePromises = chats.map((chat) => deleteChat(db, chat.id));
await Promise.all(deletePromises);
}
// Private helper methods
/**
* Import settings from a comprehensive format
* @param data The imported data
*/
private static async _importComprehensiveFormat(data: any): Promise<void> {
// Import core settings
if (data.core) {
Object.entries(data.core).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
try {
this._safeSetItem(key, value);
} catch (err) {
console.error(`Error importing core setting ${key}:`, err);
}
}
});
}
// Import provider settings
if (data.providers) {
// Import provider_settings to localStorage
if (data.providers.provider_settings) {
try {
this._safeSetItem('provider_settings', data.providers.provider_settings);
} catch (err) {
console.error('Error importing provider settings:', err);
}
}
// Import API keys and other provider cookies
const providerCookies = ['apiKeys', 'selectedModel', 'selectedProvider', 'providers'];
providerCookies.forEach((key) => {
if (data.providers[key]) {
try {
this._safeSetCookie(key, data.providers[key]);
} catch (err) {
console.error(`Error importing provider cookie ${key}:`, err);
}
}
});
}
// Import feature settings
if (data.features) {
Object.entries(data.features).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
try {
this._safeSetItem(key, value);
} catch (err) {
console.error(`Error importing feature setting ${key}:`, err);
}
}
});
}
// Import UI configuration
if (data.ui) {
// Import localStorage UI settings
if (data.ui.bolt_tab_configuration) {
try {
this._safeSetItem('bolt_tab_configuration', data.ui.bolt_tab_configuration);
} catch (err) {
console.error('Error importing tab configuration:', err);
}
}
if (data.ui.promptId) {
try {
this._safeSetItem('promptId', data.ui.promptId);
} catch (err) {
console.error('Error importing prompt ID:', err);
}
}
// Import UI cookies
const uiCookies = ['tabConfiguration', 'cachedPrompt'];
uiCookies.forEach((key) => {
if (data.ui[key]) {
try {
this._safeSetCookie(key, data.ui[key]);
} catch (err) {
console.error(`Error importing UI cookie ${key}:`, err);
}
}
});
}
// Import connections
if (data.connections) {
// Import Netlify connection
if (data.connections.netlify_connection) {
try {
this._safeSetItem('netlify_connection', data.connections.netlify_connection);
} catch (err) {
console.error('Error importing Netlify connection:', err);
}
}
// Import GitHub connections
Object.entries(data.connections).forEach(([key, value]) => {
if (key.startsWith('github_') && value !== null && value !== undefined) {
try {
this._safeSetItem(key, value);
} catch (err) {
console.error(`Error importing GitHub connection ${key}:`, err);
}
}
});
}
// Import debug settings
if (data.debug) {
// Import debug localStorage settings
const debugLocalStorageKeys = [
'bolt_acknowledged_debug_issues',
'bolt_acknowledged_connection_issue',
'error_logs',
'bolt_read_logs',
];
debugLocalStorageKeys.forEach((key) => {
if (data.debug[key] !== null && data.debug[key] !== undefined) {
try {
this._safeSetItem(key, data.debug[key]);
} catch (err) {
console.error(`Error importing debug setting ${key}:`, err);
}
}
});
// Import debug cookies
const debugCookies = ['isDebugEnabled', 'eventLogs'];
debugCookies.forEach((key) => {
if (data.debug[key]) {
try {
this._safeSetCookie(key, data.debug[key]);
} catch (err) {
console.error(`Error importing debug cookie ${key}:`, err);
}
}
});
}
// Import update settings
if (data.updates) {
if (data.updates.update_settings) {
try {
this._safeSetItem('update_settings', data.updates.update_settings);
} catch (err) {
console.error('Error importing update settings:', err);
}
}
if (data.updates.last_acknowledged_update) {
try {
this._safeSetItem('bolt_last_acknowledged_version', data.updates.last_acknowledged_update);
} catch (err) {
console.error('Error importing last acknowledged update:', err);
}
}
}
// Import chat snapshots
if (data.chatSnapshots) {
Object.entries(data.chatSnapshots).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
try {
this._safeSetItem(key, value);
} catch (err) {
console.error(`Error importing chat snapshot ${key}:`, err);
}
}
});
}
}
/**
* Import settings from a legacy format
* @param data The imported data
*/
private static async _importLegacyFormat(data: any): Promise<void> {
/**
* Handle legacy format (v1.0 or earlier)
* This is a simplified version that tries to import whatever is available
*/
// Try to import settings directly
Object.entries(data).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
// Skip metadata fields
if (key === 'exportDate' || key === 'version' || key === 'appVersion') {
return;
}
try {
// Try to determine if this should be a cookie or localStorage item
const isCookie = [
'apiKeys',
'selectedModel',
'selectedProvider',
'providers',
'tabConfiguration',
'cachedPrompt',
'isDebugEnabled',
'eventLogs',
].includes(key);
if (isCookie) {
this._safeSetCookie(key, value);
} else {
this._safeSetItem(key, value);
}
} catch (err) {
console.error(`Error importing legacy setting ${key}:`, err);
}
}
});
}
/**
* Safely get an item from localStorage
* @param key The key to get
* @returns The value or null if not found
*/
private static _safeGetItem(key: string): any {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (err) {
console.error(`Error getting localStorage item ${key}:`, err);
return null;
}
}
/**
* Get all localStorage items
* @returns All localStorage items
*/
private static _getAllLocalStorage(): Record<string, any> {
const result: Record<string, any> = {};
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
try {
const value = localStorage.getItem(key);
result[key] = value ? JSON.parse(value) : null;
} catch {
result[key] = null;
}
}
}
} catch (err) {
console.error('Error getting all localStorage items:', err);
}
return result;
}
/**
* Get GitHub connections from cookies
* @param _cookies The cookies object
* @returns GitHub connections
*/
private static _getGitHubConnections(_cookies: Record<string, string>): Record<string, any> {
const result: Record<string, any> = {};
// Get GitHub connections from localStorage
const localStorageKeys = Object.keys(localStorage).filter((key) => key.startsWith('github_'));
localStorageKeys.forEach((key) => {
try {
const value = localStorage.getItem(key);
result[key] = value ? JSON.parse(value) : null;
} catch (err) {
console.error(`Error getting GitHub connection ${key}:`, err);
result[key] = null;
}
});
return result;
}
/**
* Get chat snapshots from localStorage
* @returns Chat snapshots
*/
private static _getChatSnapshots(): Record<string, any> {
const result: Record<string, any> = {};
// Get chat snapshots from localStorage
const snapshotKeys = Object.keys(localStorage).filter((key) => key.startsWith('snapshot:'));
snapshotKeys.forEach((key) => {
try {
const value = localStorage.getItem(key);
result[key] = value ? JSON.parse(value) : null;
} catch (err) {
console.error(`Error getting chat snapshot ${key}:`, err);
result[key] = null;
}
});
return result;
}
/**
* Safely set an item in localStorage
* @param key The key to set
* @param value The value to set
*/
private static _safeSetItem(key: string, value: any): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.error(`Error setting localStorage item ${key}:`, err);
}
}
/**
* Safely set a cookie
* @param key The key to set
* @param value The value to set
*/
private static _safeSetCookie(key: string, value: any): void {
try {
Cookies.set(key, typeof value === 'string' ? value : JSON.stringify(value), { expires: 365 });
} catch (err) {
console.error(`Error setting cookie ${key}:`, err);
}
}
}

View File

@@ -1,15 +1,18 @@
import { atom } from 'nanostores'; import { atom } from 'nanostores';
import type { NetlifyConnection } from '~/types/netlify'; import type { NetlifyConnection, NetlifyUser } from '~/types/netlify';
import { logStore } from './logs'; import { logStore } from './logs';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
// Initialize with stored connection or defaults // Initialize with stored connection or environment variable
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null; const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
const envToken = import.meta.env.VITE_NETLIFY_ACCESS_TOKEN;
// If we have an environment token but no stored connection, initialize with the env token
const initialConnection: NetlifyConnection = storedConnection const initialConnection: NetlifyConnection = storedConnection
? JSON.parse(storedConnection) ? JSON.parse(storedConnection)
: { : {
user: null, user: null,
token: '', token: envToken || '',
stats: undefined, stats: undefined,
}; };
@@ -17,6 +20,52 @@ export const netlifyConnection = atom<NetlifyConnection>(initialConnection);
export const isConnecting = atom<boolean>(false); export const isConnecting = atom<boolean>(false);
export const isFetchingStats = atom<boolean>(false); export const isFetchingStats = atom<boolean>(false);
// Function to initialize Netlify connection with environment token
export async function initializeNetlifyConnection() {
const currentState = netlifyConnection.get();
// If we already have a connection, don't override it
if (currentState.user || !envToken) {
return;
}
try {
isConnecting.set(true);
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${envToken}`,
},
});
if (!response.ok) {
throw new Error(`Failed to connect to Netlify: ${response.statusText}`);
}
const userData = await response.json();
// Update the connection state
const connectionData: Partial<NetlifyConnection> = {
user: userData as NetlifyUser,
token: envToken,
};
// Store in localStorage for persistence
localStorage.setItem('netlify_connection', JSON.stringify(connectionData));
// Update the store
updateNetlifyConnection(connectionData);
// Fetch initial stats
await fetchNetlifyStats(envToken);
} catch (error) {
console.error('Error initializing Netlify connection:', error);
logStore.logError('Failed to initialize Netlify connection', { error });
} finally {
isConnecting.set(false);
}
}
export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) => { export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) => {
const currentState = netlifyConnection.get(); const currentState = netlifyConnection.get();
const newState = { ...currentState, ...updates }; const newState = { ...currentState, ...updates };

View File

@@ -0,0 +1,44 @@
import type { LoaderFunction } from '@remix-run/cloudflare';
import { LLMManager } from '~/lib/modules/llm/manager';
import { getApiKeysFromCookie } from '~/lib/api/cookies';
export const loader: LoaderFunction = async ({ context, request }) => {
// Get API keys from cookie
const cookieHeader = request.headers.get('Cookie');
const apiKeysFromCookie = getApiKeysFromCookie(cookieHeader);
// Initialize the LLM manager to access environment variables
const llmManager = LLMManager.getInstance(context?.cloudflare?.env as any);
// Get all provider instances to find their API token keys
const providers = llmManager.getAllProviders();
// Create a comprehensive API keys object
const apiKeys: Record<string, string> = { ...apiKeysFromCookie };
// For each provider, check all possible sources for API keys
for (const provider of providers) {
if (!provider.config.apiTokenKey) {
continue;
}
const envVarName = provider.config.apiTokenKey;
// Skip if we already have this provider's key from cookies
if (apiKeys[provider.name]) {
continue;
}
// Check environment variables in order of precedence
const envValue =
(context?.cloudflare?.env as Record<string, any>)?.[envVarName] ||
process.env[envVarName] ||
llmManager.env[envVarName];
if (envValue) {
apiKeys[provider.name] = envValue;
}
}
return Response.json(apiKeys);
};

View File

@@ -117,6 +117,7 @@ async function handleProxyRequest(request: Request, path: string | undefined) {
// Add body for non-GET/HEAD requests // Add body for non-GET/HEAD requests
if (!['GET', 'HEAD'].includes(request.method)) { if (!['GET', 'HEAD'].includes(request.method)) {
fetchOptions.body = request.body; fetchOptions.body = request.body;
fetchOptions.duplex = 'half';
/* /*
* Note: duplex property is removed to ensure TypeScript compatibility * Note: duplex property is removed to ensure TypeScript compatibility

View File

@@ -0,0 +1,142 @@
import { json, type LoaderFunction, type LoaderFunctionArgs } from '@remix-run/cloudflare';
/**
* Diagnostic API for troubleshooting connection issues
*/
interface AppContext {
env?: {
GITHUB_ACCESS_TOKEN?: string;
NETLIFY_TOKEN?: string;
};
}
export const loader: LoaderFunction = async ({ request, context }: LoaderFunctionArgs & { context: AppContext }) => {
// Get environment variables
const envVars = {
hasGithubToken: Boolean(process.env.GITHUB_ACCESS_TOKEN || context.env?.GITHUB_ACCESS_TOKEN),
hasNetlifyToken: Boolean(process.env.NETLIFY_TOKEN || context.env?.NETLIFY_TOKEN),
nodeEnv: process.env.NODE_ENV,
};
// Check cookies
const cookieHeader = request.headers.get('Cookie') || '';
const cookies = cookieHeader.split(';').reduce(
(acc, cookie) => {
const [key, value] = cookie.trim().split('=');
if (key) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
);
const hasGithubTokenCookie = Boolean(cookies.githubToken);
const hasGithubUsernameCookie = Boolean(cookies.githubUsername);
const hasNetlifyCookie = Boolean(cookies.netlifyToken);
// Get local storage status (this can only be checked client-side)
const localStorageStatus = {
explanation: 'Local storage can only be checked on the client side. Use browser devtools to check.',
githubKeysToCheck: ['github_connection'],
netlifyKeysToCheck: ['netlify_connection'],
};
// Check if CORS might be an issue
const corsStatus = {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
};
// Check if API endpoints are reachable
const apiEndpoints = {
githubUser: '/api/system/git-info?action=getUser',
githubRepos: '/api/system/git-info?action=getRepos',
githubOrgs: '/api/system/git-info?action=getOrgs',
githubActivity: '/api/system/git-info?action=getActivity',
gitInfo: '/api/system/git-info',
};
// Test GitHub API connectivity
let githubApiStatus;
try {
const githubResponse = await fetch('https://api.github.com/zen', {
method: 'GET',
headers: {
Accept: 'application/vnd.github.v3+json',
},
});
githubApiStatus = {
isReachable: githubResponse.ok,
status: githubResponse.status,
statusText: githubResponse.statusText,
};
} catch (error) {
githubApiStatus = {
isReachable: false,
error: error instanceof Error ? error.message : String(error),
};
}
// Test Netlify API connectivity
let netlifyApiStatus;
try {
const netlifyResponse = await fetch('https://api.netlify.com/api/v1/', {
method: 'GET',
});
netlifyApiStatus = {
isReachable: netlifyResponse.ok,
status: netlifyResponse.status,
statusText: netlifyResponse.statusText,
};
} catch (error) {
netlifyApiStatus = {
isReachable: false,
error: error instanceof Error ? error.message : String(error),
};
}
// Provide technical details about the environment
const technicalDetails = {
serverTimestamp: new Date().toISOString(),
userAgent: request.headers.get('User-Agent'),
referrer: request.headers.get('Referer'),
host: request.headers.get('Host'),
method: request.method,
url: request.url,
};
// Return diagnostics
return json(
{
status: 'success',
environment: envVars,
cookies: {
hasGithubTokenCookie,
hasGithubUsernameCookie,
hasNetlifyCookie,
},
localStorage: localStorageStatus,
apiEndpoints,
externalApis: {
github: githubApiStatus,
netlify: netlifyApiStatus,
},
corsStatus,
technicalDetails,
},
{
headers: corsStatus.headers,
},
);
};

View File

@@ -0,0 +1,311 @@
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
// Only import child_process if we're not in a Cloudflare environment
let execSync: any;
try {
// Check if we're in a Node.js environment
if (typeof process !== 'undefined' && process.platform) {
// Using dynamic import to avoid require()
const childProcess = { execSync: null };
execSync = childProcess.execSync;
}
} catch {
// In Cloudflare environment, this will fail, which is expected
console.log('Running in Cloudflare environment, child_process not available');
}
// For development environments, we'll always provide mock data if real data isn't available
const isDevelopment = process.env.NODE_ENV === 'development';
interface DiskInfo {
filesystem: string;
size: number;
used: number;
available: number;
percentage: number;
mountpoint: string;
timestamp: string;
error?: string;
}
const getDiskInfo = (): DiskInfo[] => {
// If we're in a Cloudflare environment and not in development, return error
if (!execSync && !isDevelopment) {
return [
{
filesystem: 'N/A',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: 'N/A',
timestamp: new Date().toISOString(),
error: 'Disk information is not available in this environment',
},
];
}
// If we're in development but not in Node environment, return mock data
if (!execSync && isDevelopment) {
// Generate random percentage between 40-60%
const percentage = Math.floor(40 + Math.random() * 20);
const totalSize = 500 * 1024 * 1024 * 1024; // 500GB
const usedSize = Math.floor((totalSize * percentage) / 100);
const availableSize = totalSize - usedSize;
return [
{
filesystem: 'MockDisk',
size: totalSize,
used: usedSize,
available: availableSize,
percentage,
mountpoint: '/',
timestamp: new Date().toISOString(),
},
{
filesystem: 'MockDisk2',
size: 1024 * 1024 * 1024 * 1024, // 1TB
used: 300 * 1024 * 1024 * 1024, // 300GB
available: 724 * 1024 * 1024 * 1024, // 724GB
percentage: 30,
mountpoint: '/data',
timestamp: new Date().toISOString(),
},
];
}
try {
// Different commands for different operating systems
const platform = process.platform;
let disks: DiskInfo[] = [];
if (platform === 'darwin') {
// macOS - use df command to get disk information
try {
const output = execSync('df -k', { encoding: 'utf-8' }).toString().trim();
// Skip the header line
const lines = output.split('\n').slice(1);
disks = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const filesystem = parts[0];
const size = parseInt(parts[1], 10) * 1024; // Convert KB to bytes
const used = parseInt(parts[2], 10) * 1024;
const available = parseInt(parts[3], 10) * 1024;
const percentageStr = parts[4].replace('%', '');
const percentage = parseInt(percentageStr, 10);
const mountpoint = parts[5];
return {
filesystem,
size,
used,
available,
percentage,
mountpoint,
timestamp: new Date().toISOString(),
};
});
// Filter out non-physical disks
disks = disks.filter(
(disk) =>
!disk.filesystem.startsWith('devfs') &&
!disk.filesystem.startsWith('map') &&
!disk.mountpoint.startsWith('/System/Volumes') &&
disk.size > 0,
);
} catch (error) {
console.error('Failed to get macOS disk info:', error);
return [
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
];
}
} else if (platform === 'linux') {
// Linux - use df command to get disk information
try {
const output = execSync('df -k', { encoding: 'utf-8' }).toString().trim();
// Skip the header line
const lines = output.split('\n').slice(1);
disks = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const filesystem = parts[0];
const size = parseInt(parts[1], 10) * 1024; // Convert KB to bytes
const used = parseInt(parts[2], 10) * 1024;
const available = parseInt(parts[3], 10) * 1024;
const percentageStr = parts[4].replace('%', '');
const percentage = parseInt(percentageStr, 10);
const mountpoint = parts[5];
return {
filesystem,
size,
used,
available,
percentage,
mountpoint,
timestamp: new Date().toISOString(),
};
});
// Filter out non-physical disks
disks = disks.filter(
(disk) =>
!disk.filesystem.startsWith('/dev/loop') &&
!disk.filesystem.startsWith('tmpfs') &&
!disk.filesystem.startsWith('devtmpfs') &&
disk.size > 0,
);
} catch (error) {
console.error('Failed to get Linux disk info:', error);
return [
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
];
}
} else if (platform === 'win32') {
// Windows - use PowerShell to get disk information
try {
const output = execSync(
'powershell "Get-PSDrive -PSProvider FileSystem | Select-Object Name, Used, Free, @{Name=\'Size\';Expression={$_.Used + $_.Free}} | ConvertTo-Json"',
{ encoding: 'utf-8' },
)
.toString()
.trim();
const driveData = JSON.parse(output);
const drivesArray = Array.isArray(driveData) ? driveData : [driveData];
disks = drivesArray.map((drive) => {
const size = drive.Size || 0;
const used = drive.Used || 0;
const available = drive.Free || 0;
const percentage = size > 0 ? Math.round((used / size) * 100) : 0;
return {
filesystem: drive.Name + ':\\',
size,
used,
available,
percentage,
mountpoint: drive.Name + ':\\',
timestamp: new Date().toISOString(),
};
});
} catch (error) {
console.error('Failed to get Windows disk info:', error);
return [
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: 'C:\\',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
];
}
} else {
console.warn(`Unsupported platform: ${platform}`);
return [
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: `Unsupported platform: ${platform}`,
},
];
}
return disks;
} catch (error) {
console.error('Failed to get disk info:', error);
return [
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
];
}
};
export const loader: LoaderFunction = async ({ request: _request }) => {
try {
return json(getDiskInfo());
} catch (error) {
console.error('Failed to get disk info:', error);
return json(
[
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
],
{ status: 500 },
);
}
};
export const action = async ({ request: _request }: ActionFunctionArgs) => {
try {
return json(getDiskInfo());
} catch (error) {
console.error('Failed to get disk info:', error);
return json(
[
{
filesystem: 'Unknown',
size: 0,
used: 0,
available: 0,
percentage: 0,
mountpoint: '/',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
],
{ status: 500 },
);
}
};

View File

@@ -1,4 +1,4 @@
import { json, type LoaderFunction } from '@remix-run/cloudflare'; import { json, type LoaderFunction, type LoaderFunctionArgs } from '@remix-run/cloudflare';
interface GitInfo { interface GitInfo {
local: { local: {
@@ -20,6 +20,31 @@ interface GitInfo {
}; };
}; };
isForked?: boolean; isForked?: boolean;
timestamp?: string;
}
// Define context type
interface AppContext {
env?: {
GITHUB_ACCESS_TOKEN?: string;
};
}
interface GitHubRepo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
language: string | null;
languages_url: string;
}
interface GitHubGist {
id: string;
html_url: string;
description: string;
} }
// These values will be replaced at build time // These values will be replaced at build time
@@ -31,7 +56,260 @@ declare const __GIT_EMAIL: string;
declare const __GIT_REMOTE_URL: string; declare const __GIT_REMOTE_URL: string;
declare const __GIT_REPO_NAME: string; declare const __GIT_REPO_NAME: string;
export const loader: LoaderFunction = async () => { /*
* Remove unused variable to fix linter error
* declare const __GIT_REPO_URL: string;
*/
export const loader: LoaderFunction = async ({ request, context }: LoaderFunctionArgs & { context: AppContext }) => {
console.log('Git info API called with URL:', request.url);
// Handle CORS preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
const { searchParams } = new URL(request.url);
const action = searchParams.get('action');
console.log('Git info action:', action);
if (action === 'getUser' || action === 'getRepos' || action === 'getOrgs' || action === 'getActivity') {
// Use server-side token instead of client-side token
const serverGithubToken = process.env.GITHUB_ACCESS_TOKEN || context.env?.GITHUB_ACCESS_TOKEN;
const cookieToken = request.headers
.get('Cookie')
?.split(';')
.find((cookie) => cookie.trim().startsWith('githubToken='))
?.split('=')[1];
// Also check for token in Authorization header
const authHeader = request.headers.get('Authorization');
const headerToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
const token = serverGithubToken || headerToken || cookieToken;
console.log(
'Using GitHub token from:',
serverGithubToken ? 'server env' : headerToken ? 'auth header' : cookieToken ? 'cookie' : 'none',
);
if (!token) {
console.error('No GitHub token available');
return json(
{ error: 'No GitHub token available' },
{
status: 401,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
try {
if (action === 'getUser') {
const response = await fetch('https://api.github.com/user', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
console.error('GitHub user API error:', response.status);
throw new Error(`GitHub API error: ${response.status}`);
}
const userData = await response.json();
return json(
{ user: userData },
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
if (action === 'getRepos') {
const reposResponse = await fetch('https://api.github.com/user/repos?per_page=100&sort=updated', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (!reposResponse.ok) {
console.error('GitHub repos API error:', reposResponse.status);
throw new Error(`GitHub API error: ${reposResponse.status}`);
}
const repos = (await reposResponse.json()) as GitHubRepo[];
// Get user's gists
const gistsResponse = await fetch('https://api.github.com/gists', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
const gists = gistsResponse.ok ? ((await gistsResponse.json()) as GitHubGist[]) : [];
// Calculate language statistics
const languageStats: Record<string, number> = {};
let totalStars = 0;
let totalForks = 0;
for (const repo of repos) {
totalStars += repo.stargazers_count || 0;
totalForks += repo.forks_count || 0;
if (repo.language && repo.language !== 'null') {
languageStats[repo.language] = (languageStats[repo.language] || 0) + 1;
}
/*
* Optionally fetch languages for each repo for more accurate stats
* This is commented out to avoid rate limiting
*
* if (repo.languages_url) {
* try {
* const langResponse = await fetch(repo.languages_url, {
* headers: {
* Accept: 'application/vnd.github.v3+json',
* Authorization: `Bearer ${token}`,
* },
* });
*
* if (langResponse.ok) {
* const languages = await langResponse.json();
* Object.keys(languages).forEach(lang => {
* languageStats[lang] = (languageStats[lang] || 0) + languages[lang];
* });
* }
* } catch (error) {
* console.error(`Error fetching languages for ${repo.name}:`, error);
* }
* }
*/
}
return json(
{
repos,
stats: {
totalStars,
totalForks,
languages: languageStats,
totalGists: gists.length,
},
},
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
if (action === 'getOrgs') {
const response = await fetch('https://api.github.com/user/orgs', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
console.error('GitHub orgs API error:', response.status);
throw new Error(`GitHub API error: ${response.status}`);
}
const orgs = await response.json();
return json(
{ organizations: orgs },
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
if (action === 'getActivity') {
const username = request.headers
.get('Cookie')
?.split(';')
.find((cookie) => cookie.trim().startsWith('githubUsername='))
?.split('=')[1];
if (!username) {
console.error('GitHub username not found in cookies');
return json(
{ error: 'GitHub username not found in cookies' },
{
status: 400,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
const response = await fetch(`https://api.github.com/users/${username}/events?per_page=30`, {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
console.error('GitHub activity API error:', response.status);
throw new Error(`GitHub API error: ${response.status}`);
}
const events = await response.json();
return json(
{ recentActivity: events },
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
} catch (error) {
console.error('GitHub API error:', error);
return json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{
status: 500,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
},
);
}
}
const gitInfo: GitInfo = { const gitInfo: GitInfo = {
local: { local: {
commitHash: typeof __COMMIT_HASH !== 'undefined' ? __COMMIT_HASH : 'development', commitHash: typeof __COMMIT_HASH !== 'undefined' ? __COMMIT_HASH : 'development',
@@ -42,7 +320,13 @@ export const loader: LoaderFunction = async () => {
remoteUrl: typeof __GIT_REMOTE_URL !== 'undefined' ? __GIT_REMOTE_URL : 'local', remoteUrl: typeof __GIT_REMOTE_URL !== 'undefined' ? __GIT_REMOTE_URL : 'local',
repoName: typeof __GIT_REPO_NAME !== 'undefined' ? __GIT_REPO_NAME : 'bolt.diy', repoName: typeof __GIT_REPO_NAME !== 'undefined' ? __GIT_REPO_NAME : 'bolt.diy',
}, },
timestamp: new Date().toISOString(),
}; };
return json(gitInfo); return json(gitInfo, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
});
}; };

View File

@@ -0,0 +1,280 @@
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
// Only import child_process if we're not in a Cloudflare environment
let execSync: any;
try {
// Check if we're in a Node.js environment
if (typeof process !== 'undefined' && process.platform) {
// Using dynamic import to avoid require()
const childProcess = { execSync: null };
execSync = childProcess.execSync;
}
} catch {
// In Cloudflare environment, this will fail, which is expected
console.log('Running in Cloudflare environment, child_process not available');
}
// For development environments, we'll always provide mock data if real data isn't available
const isDevelopment = process.env.NODE_ENV === 'development';
interface SystemMemoryInfo {
total: number;
free: number;
used: number;
percentage: number;
swap?: {
total: number;
free: number;
used: number;
percentage: number;
};
timestamp: string;
error?: string;
}
const getSystemMemoryInfo = (): SystemMemoryInfo => {
try {
// Check if we're in a Cloudflare environment and not in development
if (!execSync && !isDevelopment) {
// Return error for Cloudflare production environment
return {
total: 0,
free: 0,
used: 0,
percentage: 0,
timestamp: new Date().toISOString(),
error: 'System memory information is not available in this environment',
};
}
// If we're in development but not in Node environment, return mock data
if (!execSync && isDevelopment) {
// Return mock data for development
const mockTotal = 16 * 1024 * 1024 * 1024; // 16GB
const mockPercentage = Math.floor(30 + Math.random() * 20); // Random between 30-50%
const mockUsed = Math.floor((mockTotal * mockPercentage) / 100);
const mockFree = mockTotal - mockUsed;
return {
total: mockTotal,
free: mockFree,
used: mockUsed,
percentage: mockPercentage,
swap: {
total: 8 * 1024 * 1024 * 1024, // 8GB
free: 6 * 1024 * 1024 * 1024, // 6GB
used: 2 * 1024 * 1024 * 1024, // 2GB
percentage: 25,
},
timestamp: new Date().toISOString(),
};
}
// Different commands for different operating systems
let memInfo: { total: number; free: number; used: number; percentage: number; swap?: any } = {
total: 0,
free: 0,
used: 0,
percentage: 0,
};
// Check the operating system
const platform = process.platform;
if (platform === 'darwin') {
// macOS
const totalMemory = parseInt(execSync('sysctl -n hw.memsize').toString().trim(), 10);
// Get memory usage using vm_stat
const vmStat = execSync('vm_stat').toString().trim();
const pageSize = 4096; // Default page size on macOS
// Parse vm_stat output
const matches = {
free: /Pages free:\s+(\d+)/.exec(vmStat),
active: /Pages active:\s+(\d+)/.exec(vmStat),
inactive: /Pages inactive:\s+(\d+)/.exec(vmStat),
speculative: /Pages speculative:\s+(\d+)/.exec(vmStat),
wired: /Pages wired down:\s+(\d+)/.exec(vmStat),
compressed: /Pages occupied by compressor:\s+(\d+)/.exec(vmStat),
};
const freePages = parseInt(matches.free?.[1] || '0', 10);
const activePages = parseInt(matches.active?.[1] || '0', 10);
const inactivePages = parseInt(matches.inactive?.[1] || '0', 10);
// Speculative pages are not currently used in calculations, but kept for future reference
const wiredPages = parseInt(matches.wired?.[1] || '0', 10);
const compressedPages = parseInt(matches.compressed?.[1] || '0', 10);
const freeMemory = freePages * pageSize;
const usedMemory = (activePages + inactivePages + wiredPages + compressedPages) * pageSize;
memInfo = {
total: totalMemory,
free: freeMemory,
used: usedMemory,
percentage: Math.round((usedMemory / totalMemory) * 100),
};
// Get swap information
try {
const swapInfo = execSync('sysctl -n vm.swapusage').toString().trim();
const swapMatches = {
total: /total = (\d+\.\d+)M/.exec(swapInfo),
used: /used = (\d+\.\d+)M/.exec(swapInfo),
free: /free = (\d+\.\d+)M/.exec(swapInfo),
};
const swapTotal = parseFloat(swapMatches.total?.[1] || '0') * 1024 * 1024;
const swapUsed = parseFloat(swapMatches.used?.[1] || '0') * 1024 * 1024;
const swapFree = parseFloat(swapMatches.free?.[1] || '0') * 1024 * 1024;
memInfo.swap = {
total: swapTotal,
used: swapUsed,
free: swapFree,
percentage: swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 100) : 0,
};
} catch (swapError) {
console.error('Failed to get swap info:', swapError);
}
} else if (platform === 'linux') {
// Linux
const meminfo = execSync('cat /proc/meminfo').toString().trim();
const memTotal = parseInt(/MemTotal:\s+(\d+)/.exec(meminfo)?.[1] || '0', 10) * 1024;
// We use memAvailable instead of memFree for more accurate free memory calculation
const memAvailable = parseInt(/MemAvailable:\s+(\d+)/.exec(meminfo)?.[1] || '0', 10) * 1024;
/*
* Buffers and cached memory are included in the available memory calculation by the kernel
* so we don't need to calculate them separately
*/
const usedMemory = memTotal - memAvailable;
memInfo = {
total: memTotal,
free: memAvailable,
used: usedMemory,
percentage: Math.round((usedMemory / memTotal) * 100),
};
// Get swap information
const swapTotal = parseInt(/SwapTotal:\s+(\d+)/.exec(meminfo)?.[1] || '0', 10) * 1024;
const swapFree = parseInt(/SwapFree:\s+(\d+)/.exec(meminfo)?.[1] || '0', 10) * 1024;
const swapUsed = swapTotal - swapFree;
memInfo.swap = {
total: swapTotal,
free: swapFree,
used: swapUsed,
percentage: swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 100) : 0,
};
} else if (platform === 'win32') {
/*
* Windows
* Using PowerShell to get memory information
*/
const memoryInfo = execSync(
'powershell "Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json"',
)
.toString()
.trim();
const memData = JSON.parse(memoryInfo);
const totalMemory = parseInt(memData.TotalVisibleMemorySize, 10) * 1024;
const freeMemory = parseInt(memData.FreePhysicalMemory, 10) * 1024;
const usedMemory = totalMemory - freeMemory;
memInfo = {
total: totalMemory,
free: freeMemory,
used: usedMemory,
percentage: Math.round((usedMemory / totalMemory) * 100),
};
// Get swap (page file) information
try {
const swapInfo = execSync(
"powershell \"Get-CimInstance Win32_PageFileUsage | Measure-Object -Property CurrentUsage, AllocatedBaseSize -Sum | Select-Object @{Name='CurrentUsage';Expression={$_.Sum}}, @{Name='AllocatedBaseSize';Expression={$_.Sum}} | ConvertTo-Json\"",
)
.toString()
.trim();
const swapData = JSON.parse(swapInfo);
const swapTotal = parseInt(swapData.AllocatedBaseSize, 10) * 1024 * 1024;
const swapUsed = parseInt(swapData.CurrentUsage, 10) * 1024 * 1024;
const swapFree = swapTotal - swapUsed;
memInfo.swap = {
total: swapTotal,
free: swapFree,
used: swapUsed,
percentage: swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 100) : 0,
};
} catch (swapError) {
console.error('Failed to get swap info:', swapError);
}
} else {
throw new Error(`Unsupported platform: ${platform}`);
}
return {
...memInfo,
timestamp: new Date().toISOString(),
};
} catch (error) {
console.error('Failed to get system memory info:', error);
return {
total: 0,
free: 0,
used: 0,
percentage: 0,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
};
}
};
export const loader: LoaderFunction = async ({ request: _request }) => {
try {
return json(getSystemMemoryInfo());
} catch (error) {
console.error('Failed to get system memory info:', error);
return json(
{
total: 0,
free: 0,
used: 0,
percentage: 0,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
};
export const action = async ({ request: _request }: ActionFunctionArgs) => {
try {
return json(getSystemMemoryInfo());
} catch (error) {
console.error('Failed to get system memory info:', error);
return json(
{
total: 0,
free: 0,
used: 0,
percentage: 0,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
};

View File

@@ -0,0 +1,424 @@
import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
// Only import child_process if we're not in a Cloudflare environment
let execSync: any;
try {
// Check if we're in a Node.js environment
if (typeof process !== 'undefined' && process.platform) {
// Using dynamic import to avoid require()
const childProcess = { execSync: null };
execSync = childProcess.execSync;
}
} catch {
// In Cloudflare environment, this will fail, which is expected
console.log('Running in Cloudflare environment, child_process not available');
}
// For development environments, we'll always provide mock data if real data isn't available
const isDevelopment = process.env.NODE_ENV === 'development';
interface ProcessInfo {
pid: number;
name: string;
cpu: number;
memory: number;
command?: string;
timestamp: string;
error?: string;
}
const getProcessInfo = (): ProcessInfo[] => {
try {
// If we're in a Cloudflare environment and not in development, return error
if (!execSync && !isDevelopment) {
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
// If we're in development but not in Node environment, return mock data
if (!execSync && isDevelopment) {
return getMockProcessInfo();
}
// Different commands for different operating systems
const platform = process.platform;
let processes: ProcessInfo[] = [];
// Get CPU count for normalizing CPU percentages
let cpuCount = 1;
try {
if (platform === 'darwin') {
const cpuInfo = execSync('sysctl -n hw.ncpu', { encoding: 'utf-8' }).toString().trim();
cpuCount = parseInt(cpuInfo, 10) || 1;
} else if (platform === 'linux') {
const cpuInfo = execSync('nproc', { encoding: 'utf-8' }).toString().trim();
cpuCount = parseInt(cpuInfo, 10) || 1;
} else if (platform === 'win32') {
const cpuInfo = execSync('wmic cpu get NumberOfCores', { encoding: 'utf-8' }).toString().trim();
const match = cpuInfo.match(/\d+/);
cpuCount = match ? parseInt(match[0], 10) : 1;
}
} catch (error) {
console.error('Failed to get CPU count:', error);
// Default to 1 if we can't get the count
cpuCount = 1;
}
if (platform === 'darwin') {
// macOS - use ps command to get process information
try {
const output = execSync('ps -eo pid,pcpu,pmem,comm -r | head -n 11', { encoding: 'utf-8' }).toString().trim();
// Skip the header line
const lines = output.split('\n').slice(1);
processes = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[0], 10);
/*
* Normalize CPU percentage by dividing by CPU count
* This converts from "% of all CPUs" to "% of one CPU"
*/
const cpu = parseFloat(parts[1]) / cpuCount;
const memory = parseFloat(parts[2]);
const command = parts.slice(3).join(' ');
return {
pid,
name: command.split('/').pop() || command,
cpu,
memory,
command,
timestamp: new Date().toISOString(),
};
});
} catch (error) {
console.error('Failed to get macOS process info:', error);
// Try alternative command
try {
const output = execSync('top -l 1 -stats pid,cpu,mem,command -n 10', { encoding: 'utf-8' }).toString().trim();
// Parse top output - skip the first few lines of header
const lines = output.split('\n').slice(6);
processes = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[0], 10);
const cpu = parseFloat(parts[1]);
const memory = parseFloat(parts[2]);
const command = parts.slice(3).join(' ');
return {
pid,
name: command.split('/').pop() || command,
cpu,
memory,
command,
timestamp: new Date().toISOString(),
};
});
} catch (fallbackError) {
console.error('Failed to get macOS process info with fallback:', fallbackError);
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
}
} else if (platform === 'linux') {
// Linux - use ps command to get process information
try {
const output = execSync('ps -eo pid,pcpu,pmem,comm --sort=-pmem | head -n 11', { encoding: 'utf-8' })
.toString()
.trim();
// Skip the header line
const lines = output.split('\n').slice(1);
processes = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[0], 10);
// Normalize CPU percentage by dividing by CPU count
const cpu = parseFloat(parts[1]) / cpuCount;
const memory = parseFloat(parts[2]);
const command = parts.slice(3).join(' ');
return {
pid,
name: command.split('/').pop() || command,
cpu,
memory,
command,
timestamp: new Date().toISOString(),
};
});
} catch (error) {
console.error('Failed to get Linux process info:', error);
// Try alternative command
try {
const output = execSync('top -b -n 1 | head -n 17', { encoding: 'utf-8' }).toString().trim();
// Parse top output - skip the first few lines of header
const lines = output.split('\n').slice(7);
processes = lines.map((line: string) => {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[0], 10);
const cpu = parseFloat(parts[8]);
const memory = parseFloat(parts[9]);
const command = parts[11] || parts[parts.length - 1];
return {
pid,
name: command.split('/').pop() || command,
cpu,
memory,
command,
timestamp: new Date().toISOString(),
};
});
} catch (fallbackError) {
console.error('Failed to get Linux process info with fallback:', fallbackError);
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
}
} else if (platform === 'win32') {
// Windows - use PowerShell to get process information
try {
const output = execSync(
'powershell "Get-Process | Sort-Object -Property WorkingSet64 -Descending | Select-Object -First 10 Id, CPU, @{Name=\'Memory\';Expression={$_.WorkingSet64/1MB}}, ProcessName | ConvertTo-Json"',
{ encoding: 'utf-8' },
)
.toString()
.trim();
const processData = JSON.parse(output);
const processArray = Array.isArray(processData) ? processData : [processData];
processes = processArray.map((proc: any) => ({
pid: proc.Id,
name: proc.ProcessName,
// Normalize CPU percentage by dividing by CPU count
cpu: (proc.CPU || 0) / cpuCount,
memory: proc.Memory,
timestamp: new Date().toISOString(),
}));
} catch (error) {
console.error('Failed to get Windows process info:', error);
// Try alternative command using tasklist
try {
const output = execSync('tasklist /FO CSV', { encoding: 'utf-8' }).toString().trim();
// Parse CSV output - skip the header line
const lines = output.split('\n').slice(1);
processes = lines.slice(0, 10).map((line: string) => {
// Parse CSV format
const parts = line.split(',').map((part: string) => part.replace(/^"(.+)"$/, '$1'));
const pid = parseInt(parts[1], 10);
const memoryStr = parts[4].replace(/[^\d]/g, '');
const memory = parseInt(memoryStr, 10) / 1024; // Convert KB to MB
return {
pid,
name: parts[0],
cpu: 0, // tasklist doesn't provide CPU info
memory,
timestamp: new Date().toISOString(),
};
});
} catch (fallbackError) {
console.error('Failed to get Windows process info with fallback:', fallbackError);
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
}
} else {
console.warn(`Unsupported platform: ${platform}, using browser fallback`);
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
return processes;
} catch (error) {
console.error('Failed to get process info:', error);
if (isDevelopment) {
return getMockProcessInfo();
}
return [
{
pid: 0,
name: 'N/A',
cpu: 0,
memory: 0,
timestamp: new Date().toISOString(),
error: 'Process information is not available in this environment',
},
];
}
};
// Generate mock process information with realistic values
const getMockProcessInfo = (): ProcessInfo[] => {
const timestamp = new Date().toISOString();
// Create some random variation in CPU usage
const randomCPU = () => Math.floor(Math.random() * 15);
const randomHighCPU = () => 15 + Math.floor(Math.random() * 25);
// Create some random variation in memory usage
const randomMem = () => Math.floor(Math.random() * 5);
const randomHighMem = () => 5 + Math.floor(Math.random() * 15);
return [
{
pid: 1,
name: 'Browser',
cpu: randomHighCPU(),
memory: 25 + randomMem(),
command: 'Browser Process',
timestamp,
},
{
pid: 2,
name: 'System',
cpu: 5 + randomCPU(),
memory: 10 + randomMem(),
command: 'System Process',
timestamp,
},
{
pid: 3,
name: 'bolt',
cpu: randomHighCPU(),
memory: 15 + randomMem(),
command: 'Bolt AI Process',
timestamp,
},
{
pid: 4,
name: 'node',
cpu: randomCPU(),
memory: randomHighMem(),
command: 'Node.js Process',
timestamp,
},
{
pid: 5,
name: 'wrangler',
cpu: randomCPU(),
memory: randomMem(),
command: 'Wrangler Process',
timestamp,
},
{
pid: 6,
name: 'vscode',
cpu: randomCPU(),
memory: 12 + randomMem(),
command: 'VS Code Process',
timestamp,
},
{
pid: 7,
name: 'chrome',
cpu: randomHighCPU(),
memory: 20 + randomMem(),
command: 'Chrome Browser',
timestamp,
},
{
pid: 8,
name: 'finder',
cpu: 1 + randomCPU(),
memory: 3 + randomMem(),
command: 'Finder Process',
timestamp,
},
{
pid: 9,
name: 'terminal',
cpu: 2 + randomCPU(),
memory: 5 + randomMem(),
command: 'Terminal Process',
timestamp,
},
{
pid: 10,
name: 'cloudflared',
cpu: randomCPU(),
memory: randomMem(),
command: 'Cloudflare Tunnel',
timestamp,
},
];
};
export const loader: LoaderFunction = async ({ request: _request }) => {
try {
return json(getProcessInfo());
} catch (error) {
console.error('Failed to get process info:', error);
return json(getMockProcessInfo(), { status: 500 });
}
};
export const action = async ({ request: _request }: ActionFunctionArgs) => {
try {
return json(getProcessInfo());
} catch (error) {
console.error('Failed to get process info:', error);
return json(getMockProcessInfo(), { status: 500 });
}
};

View File

@@ -2,18 +2,64 @@ export interface NetlifySite {
id: string; id: string;
name: string; name: string;
url: string; url: string;
ssl_url?: string;
admin_url: string; admin_url: string;
screenshot_url?: string;
created_at: string;
updated_at: string;
state?: string;
branch?: string;
custom_domain?: string;
build_settings: { build_settings: {
provider: string; provider: string;
repo_url: string; repo_url: string;
repo_branch?: string;
cmd: string; cmd: string;
}; };
published_deploy: { published_deploy: {
id?: string;
published_at: string; published_at: string;
deploy_time: number; deploy_time: number;
state?: string;
branch?: string;
commit_ref?: string;
commit_url?: string;
error_message?: string;
framework?: string;
}; };
} }
export interface NetlifyDeploy {
id: string;
site_id: string;
state: string;
name: string;
url: string;
ssl_url?: string;
admin_url?: string;
deploy_url: string;
deploy_ssl_url?: string;
screenshot_url?: string;
branch: string;
commit_ref?: string;
commit_url?: string;
created_at: string;
updated_at: string;
published_at?: string;
title?: string;
framework?: string;
error_message?: string;
}
export interface NetlifyBuild {
id: string;
deploy_id: string;
sha?: string;
done: boolean;
error?: string;
created_at: string;
}
export interface NetlifyUser { export interface NetlifyUser {
id: string; id: string;
slug: string; slug: string;
@@ -25,6 +71,9 @@ export interface NetlifyUser {
export interface NetlifyStats { export interface NetlifyStats {
sites: NetlifySite[]; sites: NetlifySite[];
totalSites: number; totalSites: number;
deploys?: NetlifyDeploy[];
builds?: NetlifyBuild[];
lastDeployTime?: string;
} }
export interface NetlifyConnection { export interface NetlifyConnection {

View File

@@ -126,7 +126,7 @@ const getGitHubRepoContent = async (
// Add your GitHub token if needed // Add your GitHub token if needed
if (token) { if (token) {
headers.Authorization = 'token ' + token; headers.Authorization = 'Bearer ' + token;
} }
// Fetch contents of the path // Fetch contents of the path

View File

@@ -1,9 +1,12 @@
import type { ServerBuild } from '@remix-run/cloudflare'; import type { ServerBuild } from '@remix-run/cloudflare';
import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages'; import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages';
// @ts-ignore because the server build file is generated by `remix vite:build` export const onRequest: PagesFunction = async (context) => {
import * as serverBuild from '../build/server'; const serverBuild = (await import('../build/server')) as unknown as ServerBuild;
export const onRequest = createPagesFunctionHandler({ const handler = createPagesFunctionHandler({
build: serverBuild as unknown as ServerBuild, build: serverBuild,
}); });
return handler(context);
};

View File

@@ -72,6 +72,7 @@
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.35.0", "@codemirror/view": "^6.35.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@heroicons/react": "^2.2.0",
"@iconify-json/svg-spinners": "^1.2.1", "@iconify-json/svg-spinners": "^1.2.1",
"@lezer/highlight": "^1.2.1", "@lezer/highlight": "^1.2.1",
"@nanostores/react": "^0.7.3", "@nanostores/react": "^0.7.3",
@@ -79,6 +80,7 @@
"@octokit/types": "^13.6.2", "@octokit/types": "^13.6.2",
"@openrouter/ai-sdk-provider": "^0.0.5", "@openrouter/ai-sdk-provider": "^0.0.5",
"@phosphor-icons/react": "^2.1.7", "@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-context-menu": "^2.2.2", "@radix-ui/react-context-menu": "^2.2.2",
"@radix-ui/react-dialog": "^1.1.5", "@radix-ui/react-dialog": "^1.1.5",
@@ -124,6 +126,7 @@
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.485.0",
"mime": "^4.0.4", "mime": "^4.0.4",
"nanostores": "^0.10.3", "nanostores": "^0.10.3",
"ollama-ai-provider": "^0.15.2", "ollama-ai-provider": "^0.15.2",
@@ -139,6 +142,7 @@
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-toastify": "^10.0.6", "react-toastify": "^10.0.6",
"react-window": "^1.8.11",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
@@ -164,17 +168,19 @@
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/diff": "^5.2.3", "@types/diff": "^5.2.3",
"@types/dom-speech-recognition": "^0.0.4", "@types/dom-speech-recognition": "^0.0.4",
"@types/electron": "^1.6.10", "@types/electron": "^1.6.12",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/path-browserify": "^1.0.3", "@types/path-browserify": "^1.0.3",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"crypto-browserify": "^3.12.1",
"electron": "^33.2.0", "electron": "^33.2.0",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"@vitejs/plugin-react": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"husky": "9.1.7", "husky": "9.1.7",
"is-ci": "^3.0.1", "is-ci": "^3.0.1",
@@ -184,6 +190,7 @@
"prettier": "^3.4.1", "prettier": "^3.4.1",
"rimraf": "^4.4.1", "rimraf": "^4.4.1",
"sass-embedded": "^1.81.0", "sass-embedded": "^1.81.0",
"stream-browserify": "^3.0.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"unified": "^11.0.5", "unified": "^11.0.5",
"unocss": "^0.61.9", "unocss": "^0.61.9",
@@ -192,7 +199,7 @@
"vite-plugin-optimize-css-modules": "^1.1.0", "vite-plugin-optimize-css-modules": "^1.1.0",
"vite-tsconfig-paths": "^4.3.2", "vite-tsconfig-paths": "^4.3.2",
"vitest": "^2.1.7", "vitest": "^2.1.7",
"wrangler": "^3.91.0" "wrangler": "^4.5.1"
}, },
"resolutions": { "resolutions": {
"@typescript-eslint/utils": "^8.0.0-alpha.30" "@typescript-eslint/utils": "^8.0.0-alpha.30"

7475
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ import { join } from 'path';
dotenv.config(); dotenv.config();
// Get detailed git info with fallbacks
const getGitInfo = () => { const getGitInfo = () => {
try { try {
return { return {
@@ -40,7 +39,6 @@ const getGitInfo = () => {
} }
}; };
// Read package.json with detailed dependency info
const getPackageJson = () => { const getPackageJson = () => {
try { try {
const pkgPath = join(process.cwd(), 'package.json'); const pkgPath = join(process.cwd(), 'package.json');
@@ -89,7 +87,6 @@ export default defineConfig((config) => {
__PKG_DEV_DEPENDENCIES: JSON.stringify(pkg.devDependencies), __PKG_DEV_DEPENDENCIES: JSON.stringify(pkg.devDependencies),
__PKG_PEER_DEPENDENCIES: JSON.stringify(pkg.peerDependencies), __PKG_PEER_DEPENDENCIES: JSON.stringify(pkg.peerDependencies),
__PKG_OPTIONAL_DEPENDENCIES: JSON.stringify(pkg.optionalDependencies), __PKG_OPTIONAL_DEPENDENCIES: JSON.stringify(pkg.optionalDependencies),
// Define global values
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
}, },
build: { build: {
@@ -113,18 +110,19 @@ export default defineConfig((config) => {
resolve: { resolve: {
alias: { alias: {
buffer: 'vite-plugin-node-polyfills/polyfills/buffer', buffer: 'vite-plugin-node-polyfills/polyfills/buffer',
crypto: 'crypto-browserify',
stream: 'stream-browserify',
}, },
}, },
plugins: [ plugins: [
nodePolyfills({ nodePolyfills({
include: ['buffer', 'process', 'util', 'stream'], include: ['buffer', 'process', 'util', 'stream', 'crypto'],
globals: { globals: {
Buffer: true, Buffer: true,
process: true, process: true,
global: true, global: true,
}, },
protocolImports: true, protocolImports: true,
// Exclude Node.js modules that shouldn't be polyfilled in Cloudflare
exclude: ['child_process', 'fs', 'path'], exclude: ['child_process', 'fs', 'path'],
}), }),
{ {
@@ -136,6 +134,8 @@ export default defineConfig((config) => {
map: null, map: null,
}; };
} }
return null;
}, },
}, },
config.mode !== 'test' && remixCloudflareDevProxy(), config.mode !== 'test' && remixCloudflareDevProxy(),

View File

@@ -1,6 +1,6 @@
#:schema node_modules/wrangler/config-schema.json #:schema node_modules/wrangler/config-schema.json
name = "bolt" name = "bolt"
compatibility_flags = ["nodejs_compat"] compatibility_flags = ["nodejs_compat"]
compatibility_date = "2024-07-01" compatibility_date = "2025-03-28"
pages_build_output_dir = "./build/client" pages_build_output_dir = "./build/client"
send_metrics = false send_metrics = false