Revert "fix: resolve chat conversation hanging and stream interruption issues (#1971)"

This reverts commit e68593f22d.
This commit is contained in:
Stijnus
2025-09-07 00:14:13 +02:00
committed by Stijnus
parent e68593f22d
commit 37217a5c7b
61 changed files with 1432 additions and 8811 deletions

View File

@@ -3,8 +3,7 @@ import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { useStore } from '@nanostores/react';
import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify';
import type { NetlifySite, NetlifyDeploy, NetlifyBuild } from '~/types/netlify';
import { NetlifyQuickConnect } from './NetlifyQuickConnect';
import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify';
import {
CloudIcon,
BuildingLibraryIcon,
@@ -43,16 +42,29 @@ interface SiteAction {
}
export default function NetlifyConnection() {
console.log('NetlifyConnection component mounted');
const connection = useStore(netlifyConnection);
const [tokenInput, setTokenInput] = useState('');
const [fetchingStats, setFetchingStats] = useState(false);
const [sites, setSites] = useState<NetlifySite[]>([]);
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
console.log('NetlifyConnection initial state:', {
connection: {
user: connection.user,
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
},
envToken: import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
});
const [deploymentCount, setDeploymentCount] = useState(0);
const [lastUpdated, setLastUpdated] = useState('');
const [isStatsOpen, setIsStatsOpen] = useState(false);
const [activeSiteIndex, setActiveSiteIndex] = useState(0);
const [isActionLoading, setIsActionLoading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
// Add site actions
const siteActions: SiteAction[] = [
@@ -139,6 +151,8 @@ export default function NetlifyConnection() {
};
useEffect(() => {
console.log('Netlify: Running initialization useEffect');
// Initialize connection with environment token if available
initializeNetlifyConnection();
}, []);
@@ -159,6 +173,46 @@ export default function NetlifyConnection() {
}
}, [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;
// Update the connection store
updateNetlifyConnection({
user: userData,
token: tokenInput,
});
toast.success('Connected to Netlify successfully');
// Fetch stats after successful connection
fetchNetlifyStats(tokenInput);
} catch (error) {
console.error('Error connecting to Netlify:', error);
toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsConnecting(false);
setTokenInput('');
}
};
const handleDisconnect = () => {
// Clear from localStorage
localStorage.removeItem('netlify_connection');
@@ -608,15 +662,76 @@ export default function NetlifyConnection() {
{!connection.user ? (
<div className="mt-4">
<NetlifyQuickConnect
onSuccess={() => {
// Fetch stats after successful connection
if (connection.token) {
fetchNetlifyStats(connection.token);
}
}}
showInstructions={true}
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
API Token
</label>
<input
type="password"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
placeholder="Enter your Netlify API token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
{/* Debug info - remove this later */}
<div className="mt-2 text-xs text-gray-500">
<p>Debug: Token present: {connection.token ? '✅' : '❌'}</p>
<p>Debug: User present: {connection.user ? '✅' : '❌'}</p>
<p>Debug: Env token: {import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '✅' : '❌'}</p>
</div>
<div className="flex gap-2 mt-4">
<button
onClick={handleConnect}
disabled={isConnecting || !tokenInput}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
{/* Debug button - remove this later */}
<button
onClick={async () => {
console.log('Manual Netlify auto-connect test');
await initializeNetlifyConnection();
}}
className="px-3 py-2 rounded-lg text-xs bg-blue-500 text-white hover:bg-blue-600"
>
Test Auto-Connect
</button>
</div>
</div>
) : (
<div className="flex flex-col w-full gap-4 mt-4">

View File

@@ -1,226 +0,0 @@
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import { updateNetlifyConnection } from '~/lib/stores/netlify';
import { classNames } from '~/utils/classNames';
interface NetlifyQuickConnectProps {
onSuccess?: () => void;
showInstructions?: boolean;
}
export const NetlifyQuickConnect: React.FC<NetlifyQuickConnectProps> = ({ onSuccess, showInstructions = true }) => {
const [token, setToken] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const handleConnect = async () => {
if (!token.trim()) {
toast.error('Please enter your Netlify API token');
return;
}
setIsConnecting(true);
try {
// Validate token with Netlify API
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Invalid token or authentication failed');
}
const userData = (await response.json()) as any;
// Fetch initial site statistics
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
headers: {
Authorization: `Bearer ${token}`,
},
});
let sites: any[] = [];
if (sitesResponse.ok) {
sites = (await sitesResponse.json()) as any[];
}
// Update the connection store
updateNetlifyConnection({
user: userData,
token,
stats: {
sites,
totalSites: sites.length,
deploys: [],
builds: [],
lastDeployTime: '',
},
});
toast.success(`Connected to Netlify as ${userData.email || userData.name || 'User'}`);
setToken(''); // Clear the token field
if (onSuccess) {
onSuccess();
}
} catch (error) {
console.error('Netlify connection error:', error);
toast.error('Failed to connect to Netlify. Please check your token.');
} finally {
setIsConnecting(false);
}
};
return (
<div className="space-y-4">
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-bolt-elements-textPrimary">Personal Access Token</label>
{showInstructions && (
<button
onClick={() => setShowHelp(!showHelp)}
className="text-xs text-accent-500 hover:text-accent-600 flex items-center gap-1"
>
<span className={classNames('i-ph:question-circle', showHelp ? 'text-accent-600' : '')} />
How to get token
</button>
)}
</div>
<div className="relative">
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && token.trim() && !isConnecting) {
handleConnect();
}
}}
placeholder="Enter your Netlify API token"
className={classNames(
'w-full px-3 py-2 pr-10 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent',
'disabled:opacity-50',
)}
disabled={isConnecting}
/>
{token && (
<button
onClick={() => setToken('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary"
>
<span className="i-ph:x text-lg" />
</button>
)}
</div>
</div>
{showHelp && showInstructions && (
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 space-y-3 animate-in fade-in-0 slide-in-from-top-2">
<div className="flex items-start gap-2">
<span className="i-ph:info text-accent-500 mt-0.5" />
<div className="space-y-2 text-sm">
<p className="font-medium text-bolt-elements-textPrimary">
Getting your Netlify Personal Access Token:
</p>
<ol className="space-y-2 text-bolt-elements-textSecondary">
<li className="flex items-start gap-2">
<span className="text-accent-500 font-medium">1.</span>
<span>
Go to{' '}
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-accent-500 hover:text-accent-600 underline inline-flex items-center gap-1"
>
Netlify Account Settings
<span className="i-ph:arrow-square-out text-xs" />
</a>
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent-500 font-medium">2.</span>
<span>Navigate to "Applications" "Personal access tokens"</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent-500 font-medium">3.</span>
<span>Click "New access token"</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent-500 font-medium">4.</span>
<span>Give it a descriptive name (e.g., "bolt.diy deployment")</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent-500 font-medium">5.</span>
<span>Copy the token and paste it above</span>
</li>
</ol>
<div className="pt-2 border-t border-bolt-elements-borderColor">
<p className="text-xs text-bolt-elements-textTertiary">
<strong>Note:</strong> Keep your token safe! It provides full access to your Netlify account.
</p>
</div>
</div>
</div>
</div>
)}
<div className="flex gap-3">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2 transition-all text-sm font-medium flex items-center gap-2"
>
<span className="i-ph:arrow-square-out" />
Get Token
</a>
<button
onClick={handleConnect}
disabled={isConnecting || !token.trim()}
className={classNames(
'flex-1 px-4 py-2 rounded-lg font-medium transition-all text-sm',
'bg-accent-500 text-white',
'hover:bg-accent-600',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center justify-center gap-2',
)}
>
{isConnecting ? (
<>
<span className="i-svg-spinners:3-dots-scale" />
Connecting...
</>
) : (
<>
<span className="i-ph:plug-charging" />
Connect to Netlify
</>
)}
</button>
</div>
</div>
<div className="p-3 bg-accent-500/10 border border-accent-500/20 rounded-lg">
<div className="flex items-start gap-2">
<span className="i-ph:lightning text-accent-500 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-bolt-elements-textPrimary">Quick Tip</p>
<p className="text-xs text-bolt-elements-textSecondary">
Once connected, you can deploy any project with a single click directly from the editor!
</p>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,56 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from '@remix-run/react';
import { useStore } from '@nanostores/react';
import { authStore } from '~/lib/stores/auth';
import { motion } from 'framer-motion';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const navigate = useNavigate();
const authState = useStore(authStore);
useEffect(() => {
// If not loading and not authenticated, redirect to auth page
if (!authState.loading && !authState.isAuthenticated) {
navigate('/auth');
}
}, [authState.loading, authState.isAuthenticated, navigate]);
// Show loading state
if (authState.loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bolt-elements-background-depth-1">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-bolt-elements-background-depth-2 flex items-center justify-center">
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
</div>
<p className="text-bolt-elements-textSecondary">Loading your workspace...</p>
</motion.div>
</div>
);
}
// If not authenticated, don't render children (will redirect)
if (!authState.isAuthenticated) {
return null;
}
// Render protected content
return <>{children}</>;
}
// HOC for protecting pages
export function withAuth<P extends object>(wrappedComponent: React.ComponentType<P>) {
const Component = wrappedComponent;
return function ProtectedComponent(props: P) {
return (
<ProtectedRoute>
<Component {...props} />
</ProtectedRoute>
);
};
}

View File

@@ -176,18 +176,9 @@ export const AssistantMessage = memo(
</div>
</div>
</>
<div className="prose prose-invert max-w-none text-bolt-elements-textPrimary">
<Markdown
append={append}
chatMode={chatMode}
setChatMode={setChatMode}
model={model}
provider={provider}
html
>
{content}
</Markdown>
</div>
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
{content}
</Markdown>
{toolInvocations && toolInvocations.length > 0 && (
<ToolInvocations
toolInvocations={toolInvocations}

View File

@@ -1,86 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from '@remix-run/react';
import { ClientOnly } from 'remix-utils/client-only';
import { useStore } from '@nanostores/react';
import { authStore } from '~/lib/stores/auth';
import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/header/Header';
import BackgroundRays from '~/components/ui/BackgroundRays';
import { motion } from 'framer-motion';
import { UserMenu } from '~/components/header/UserMenu';
/**
* Authenticated chat component that ensures user is logged in
*/
export function AuthenticatedChat() {
const navigate = useNavigate();
const authState = useStore(authStore);
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
// Check authentication status after component mounts
const checkAuth = async () => {
// Give auth store time to initialize
await new Promise((resolve) => setTimeout(resolve, 100));
const state = authStore.get();
if (!state.loading) {
if (!state.isAuthenticated) {
navigate('/auth');
} else {
setIsInitialized(true);
}
}
};
checkAuth();
}, [navigate]);
useEffect(() => {
// Subscribe to auth changes
const unsubscribe = authStore.subscribe((state) => {
if (!state.loading && !state.isAuthenticated) {
navigate('/auth');
}
});
return () => {
unsubscribe();
};
}, [navigate]);
// Show loading state
if (authState.loading || !isInitialized) {
return (
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
<BackgroundRays />
<div className="flex-1 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-bolt-elements-background-depth-2 flex items-center justify-center">
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
</div>
<p className="text-bolt-elements-textSecondary">Initializing workspace...</p>
</motion.div>
</div>
</div>
);
}
// If not authenticated, don't render (will redirect)
if (!authState.isAuthenticated) {
return null;
}
// Render authenticated content with enhanced header
return (
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
<BackgroundRays />
<Header>
<UserMenu />
</Header>
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
</div>
);
}

View File

@@ -73,28 +73,20 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
{isUserMessage ? (
<UserMessage content={content} parts={parts} />
) : (
<>
{props.model?.includes('smartai') && index === messages.length - 1 && isStreaming && (
<div className="flex items-center gap-2 mb-2 text-sm text-blue-400">
<span className="i-ph:sparkle animate-pulse" />
<span className="font-medium">SmartAI is explaining the process...</span>
</div>
)}
<AssistantMessage
content={content}
annotations={message.annotations}
messageId={messageId}
onRewind={handleRewind}
onFork={handleFork}
append={props.append}
chatMode={props.chatMode}
setChatMode={props.setChatMode}
model={props.model}
provider={props.provider}
parts={parts}
addToolResult={props.addToolResult}
/>
</>
<AssistantMessage
content={content}
annotations={message.annotations}
messageId={messageId}
onRewind={handleRewind}
onFork={handleFork}
append={props.append}
chatMode={props.chatMode}
setChatMode={props.setChatMode}
model={props.model}
provider={props.provider}
parts={parts}
addToolResult={props.addToolResult}
/>
)}
</div>
</div>

View File

@@ -1,9 +1,84 @@
import type { ProviderInfo } from '~/types/model';
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import type { KeyboardEvent } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types';
import { classNames } from '~/utils/classNames';
// Fuzzy search utilities
const levenshteinDistance = (str1: string, str2: string): number => {
const matrix = [];
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str1.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
}
}
}
return matrix[str2.length][str1.length];
};
const fuzzyMatch = (query: string, text: string): { score: number; matches: boolean } => {
if (!query) {
return { score: 0, matches: true };
}
if (!text) {
return { score: 0, matches: false };
}
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
// Exact substring match gets highest score
if (textLower.includes(queryLower)) {
return { score: 100 - (textLower.indexOf(queryLower) / textLower.length) * 20, matches: true };
}
// Fuzzy match with reasonable threshold
const distance = levenshteinDistance(queryLower, textLower);
const maxLen = Math.max(queryLower.length, textLower.length);
const similarity = 1 - distance / maxLen;
return {
score: similarity > 0.6 ? similarity * 80 : 0,
matches: similarity > 0.6,
};
};
const highlightText = (text: string, query: string): string => {
if (!query) {
return text;
}
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 text-current">$1</mark>');
};
const formatContextSize = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`;
}
return tokens.toString();
};
interface ModelSelectorProps {
model?: string;
setModel?: (model: string) => void;
@@ -40,12 +115,14 @@ export const ModelSelector = ({
modelLoading,
}: ModelSelectorProps) => {
const [modelSearchQuery, setModelSearchQuery] = useState('');
const [debouncedModelSearchQuery, setDebouncedModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [focusedModelIndex, setFocusedModelIndex] = useState(-1);
const modelSearchInputRef = useRef<HTMLInputElement>(null);
const modelOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const [providerSearchQuery, setProviderSearchQuery] = useState('');
const [debouncedProviderSearchQuery, setDebouncedProviderSearchQuery] = useState('');
const [isProviderDropdownOpen, setIsProviderDropdownOpen] = useState(false);
const [focusedProviderIndex, setFocusedProviderIndex] = useState(-1);
const providerSearchInputRef = useRef<HTMLInputElement>(null);
@@ -53,6 +130,23 @@ export const ModelSelector = ({
const providerDropdownRef = useRef<HTMLDivElement>(null);
const [showFreeModelsOnly, setShowFreeModelsOnly] = useState(false);
// Debounce search queries
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedModelSearchQuery(modelSearchQuery);
}, 150);
return () => clearTimeout(timer);
}, [modelSearchQuery]);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedProviderSearchQuery(providerSearchQuery);
}, 150);
return () => clearTimeout(timer);
}, [providerSearchQuery]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) {
@@ -71,24 +165,64 @@ export const ModelSelector = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredModels = [...modelList]
.filter((e) => e.provider === provider?.name && e.name)
.filter((model) => {
// Apply free models filter
if (showFreeModelsOnly && !isModelLikelyFree(model, provider?.name)) {
return false;
}
const filteredModels = useMemo(() => {
const baseModels = [...modelList].filter((e) => e.provider === provider?.name && e.name);
// Apply search filter
return (
model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase())
);
});
return baseModels
.filter((model) => {
// Apply free models filter
if (showFreeModelsOnly && !isModelLikelyFree(model, provider?.name)) {
return false;
}
const filteredProviders = providerList.filter((p) =>
p.name.toLowerCase().includes(providerSearchQuery.toLowerCase()),
);
return true;
})
.map((model) => {
// Calculate search scores for fuzzy matching
const labelMatch = fuzzyMatch(debouncedModelSearchQuery, model.label);
const nameMatch = fuzzyMatch(debouncedModelSearchQuery, model.name);
const contextMatch = fuzzyMatch(debouncedModelSearchQuery, formatContextSize(model.maxTokenAllowed));
const bestScore = Math.max(labelMatch.score, nameMatch.score, contextMatch.score);
const matches = labelMatch.matches || nameMatch.matches || contextMatch.matches || !debouncedModelSearchQuery; // Show all if no query
return {
...model,
searchScore: bestScore,
searchMatches: matches,
highlightedLabel: highlightText(model.label, debouncedModelSearchQuery),
highlightedName: highlightText(model.name, debouncedModelSearchQuery),
};
})
.filter((model) => model.searchMatches)
.sort((a, b) => {
// Sort by search score (highest first), then by label
if (debouncedModelSearchQuery) {
return b.searchScore - a.searchScore;
}
return a.label.localeCompare(b.label);
});
}, [modelList, provider?.name, showFreeModelsOnly, debouncedModelSearchQuery]);
const filteredProviders = useMemo(() => {
if (!debouncedProviderSearchQuery) {
return providerList;
}
return providerList
.map((provider) => {
const match = fuzzyMatch(debouncedProviderSearchQuery, provider.name);
return {
...provider,
searchScore: match.score,
searchMatches: match.matches,
highlightedName: highlightText(provider.name, debouncedProviderSearchQuery),
};
})
.filter((provider) => provider.searchMatches)
.sort((a, b) => b.searchScore - a.searchScore);
}, [providerList, debouncedProviderSearchQuery]);
// Reset free models filter when provider changes
useEffect(() => {
@@ -97,11 +231,30 @@ export const ModelSelector = ({
useEffect(() => {
setFocusedModelIndex(-1);
}, [modelSearchQuery, isModelDropdownOpen, showFreeModelsOnly]);
}, [debouncedModelSearchQuery, isModelDropdownOpen, showFreeModelsOnly]);
useEffect(() => {
setFocusedProviderIndex(-1);
}, [providerSearchQuery, isProviderDropdownOpen]);
}, [debouncedProviderSearchQuery, isProviderDropdownOpen]);
// Clear search functions
const clearModelSearch = useCallback(() => {
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
if (modelSearchInputRef.current) {
modelSearchInputRef.current.focus();
}
}, []);
const clearProviderSearch = useCallback(() => {
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
if (providerSearchInputRef.current) {
providerSearchInputRef.current.focus();
}
}, []);
useEffect(() => {
if (isModelDropdownOpen && modelSearchInputRef.current) {
@@ -137,6 +290,7 @@ export const ModelSelector = ({
setModel?.(selectedModel.name);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
}
break;
@@ -144,12 +298,20 @@ export const ModelSelector = ({
e.preventDefault();
setIsModelDropdownOpen(false);
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
break;
case 'Tab':
if (!e.shiftKey && focusedModelIndex === filteredModels.length - 1) {
setIsModelDropdownOpen(false);
}
break;
case 'k':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
clearModelSearch();
}
break;
}
};
@@ -186,6 +348,7 @@ export const ModelSelector = ({
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
}
break;
@@ -193,12 +356,20 @@ export const ModelSelector = ({
e.preventDefault();
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
break;
case 'Tab':
if (!e.shiftKey && focusedProviderIndex === filteredProviders.length - 1) {
setIsProviderDropdownOpen(false);
}
break;
case 'k':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
clearProviderSearch();
}
break;
}
};
@@ -292,9 +463,9 @@ export const ModelSelector = ({
type="text"
value={providerSearchQuery}
onChange={(e) => setProviderSearchQuery(e.target.value)}
placeholder="Search providers..."
placeholder="Search providers... (⌘K to clear)"
className={classNames(
'w-full pl-2 py-1.5 rounded-md text-sm',
'w-full pl-8 pr-8 py-1.5 rounded-md text-sm',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
@@ -307,6 +478,19 @@ export const ModelSelector = ({
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
</div>
{providerSearchQuery && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearProviderSearch();
}}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
aria-label="Clear search"
>
<span className="i-ph:x text-bolt-elements-textTertiary text-xs" />
</button>
)}
</div>
</div>
@@ -327,7 +511,18 @@ export const ModelSelector = ({
)}
>
{filteredProviders.length === 0 ? (
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">No providers found</div>
<div className="px-3 py-3 text-sm">
<div className="text-bolt-elements-textTertiary mb-1">
{debouncedProviderSearchQuery
? `No providers match "${debouncedProviderSearchQuery}"`
: 'No providers found'}
</div>
{debouncedProviderSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try searching for provider names like "OpenAI", "Anthropic", or "Google"
</div>
)}
</div>
) : (
filteredProviders.map((providerOption, index) => (
<div
@@ -360,10 +555,15 @@ export const ModelSelector = ({
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
}}
tabIndex={focusedProviderIndex === index ? 0 : -1}
>
{providerOption.name}
<div
dangerouslySetInnerHTML={{
__html: (providerOption as any).highlightedName || providerOption.name,
}}
/>
</div>
))
)}
@@ -396,15 +596,7 @@ export const ModelSelector = ({
tabIndex={0}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 truncate">
<span className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</span>
{modelList.find((m) => m.name === model)?.isSmartAIEnabled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-gradient-to-r from-blue-500/20 to-purple-500/20 border border-blue-500/30">
<span className="i-ph:sparkle text-xs text-blue-400" />
<span className="text-xs text-blue-400 font-medium">Active</span>
</span>
)}
</div>
<div className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
@@ -449,6 +641,14 @@ export const ModelSelector = ({
</div>
)}
{/* Search Result Count */}
{debouncedModelSearchQuery && filteredModels.length > 0 && (
<div className="text-xs text-bolt-elements-textTertiary px-1">
{filteredModels.length} model{filteredModels.length !== 1 ? 's' : ''} found
{filteredModels.length > 5 && ' (showing best matches)'}
</div>
)}
{/* Search Input */}
<div className="relative">
<input
@@ -456,9 +656,9 @@ export const ModelSelector = ({
type="text"
value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)}
placeholder="Search models..."
placeholder="Search models... (⌘K to clear)"
className={classNames(
'w-full pl-2 py-1.5 rounded-md text-sm',
'w-full pl-8 pr-8 py-1.5 rounded-md text-sm',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
@@ -471,6 +671,19 @@ export const ModelSelector = ({
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
</div>
{modelSearchQuery && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearModelSearch();
}}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
aria-label="Clear search"
>
<span className="i-ph:x text-bolt-elements-textTertiary text-xs" />
</button>
)}
</div>
</div>
@@ -491,16 +704,37 @@ export const ModelSelector = ({
)}
>
{modelLoading === 'all' || modelLoading === provider?.name ? (
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">Loading...</div>
<div className="px-3 py-3 text-sm">
<div className="flex items-center gap-2 text-bolt-elements-textTertiary">
<span className="i-ph:spinner animate-spin" />
Loading models...
</div>
</div>
) : filteredModels.length === 0 ? (
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">
{showFreeModelsOnly ? 'No free models found' : 'No models found'}
<div className="px-3 py-3 text-sm">
<div className="text-bolt-elements-textTertiary mb-1">
{debouncedModelSearchQuery
? `No models match "${debouncedModelSearchQuery}"${showFreeModelsOnly ? ' (free only)' : ''}`
: showFreeModelsOnly
? 'No free models available'
: 'No models available'}
</div>
{debouncedModelSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try searching for model names, context sizes (e.g., "128k", "1M"), or capabilities
</div>
)}
{showFreeModelsOnly && !debouncedModelSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try disabling the "Free models only" filter to see all available models
</div>
)}
</div>
) : (
filteredModels.map((modelOption, index) => (
<div
ref={(el) => (modelOptionsRef.current[index] = el)}
key={index} // Consider using modelOption.name if unique
key={modelOption.name}
role="option"
aria-selected={model === modelOption.name}
className={classNames(
@@ -518,22 +752,38 @@ export const ModelSelector = ({
setModel?.(modelOption.name);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
}}
tabIndex={focusedModelIndex === index ? 0 : -1}
>
<div className="flex items-center justify-between">
<span className="flex items-center gap-2">
{modelOption.label}
{modelOption.isSmartAIEnabled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-gradient-to-r from-blue-500/20 to-purple-500/20 border border-blue-500/30">
<span className="i-ph:sparkle text-xs text-blue-400" />
<span className="text-xs text-blue-400 font-medium">SmartAI</span>
<div className="flex-1 min-w-0">
<div className="truncate">
<span
dangerouslySetInnerHTML={{
__html: (modelOption as any).highlightedLabel || modelOption.label,
}}
/>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-bolt-elements-textTertiary">
{formatContextSize(modelOption.maxTokenAllowed)} tokens
</span>
{debouncedModelSearchQuery && (modelOption as any).searchScore > 70 && (
<span className="text-xs text-green-500 font-medium">
{(modelOption as any).searchScore.toFixed(0)}% match
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{isModelLikelyFree(modelOption, provider?.name) && (
<span className="i-ph:gift text-xs text-purple-400" title="Free model" />
)}
</span>
{isModelLikelyFree(modelOption, provider?.name) && (
<span className="i-ph:gift text-xs text-purple-400 ml-2" title="Free model" />
)}
{model === modelOption.name && (
<span className="i-ph:check text-xs text-green-500" title="Selected" />
)}
</div>
</div>
</div>
))

View File

@@ -1,45 +0,0 @@
import React from 'react';
import { classNames } from '~/utils/classNames';
import type { ModelInfo } from '~/lib/modules/llm/types';
import type { ProviderInfo } from '~/types/model';
interface SmartAIToggleProps {
enabled: boolean;
onToggle: (enabled: boolean) => void;
provider?: ProviderInfo;
model?: string;
modelList: ModelInfo[];
}
export const SmartAiToggle: React.FC<SmartAIToggleProps> = ({ enabled, onToggle, provider, model, modelList }) => {
// Check if current model supports SmartAI
const currentModel = modelList.find((m) => m.name === model);
const isSupported = currentModel?.supportsSmartAI && (provider?.name === 'Anthropic' || provider?.name === 'OpenAI');
if (!isSupported) {
return null;
}
return (
<button
onClick={() => onToggle(!enabled)}
className={classNames(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all',
'border border-bolt-elements-borderColor',
enabled
? 'bg-gradient-to-r from-blue-500/20 to-purple-500/20 border-blue-500/30'
: 'bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3',
)}
title="Toggle SmartAI for detailed conversational feedback"
>
<span
className={classNames('i-ph:sparkle text-sm', enabled ? 'text-blue-400' : 'text-bolt-elements-textSecondary')}
/>
<span
className={classNames('text-xs font-medium', enabled ? 'text-blue-400' : 'text-bolt-elements-textSecondary')}
>
SmartAI {enabled ? 'ON' : 'OFF'}
</span>
</button>
);
};

View File

@@ -1,90 +0,0 @@
import React from 'react';
import { useStore } from '@nanostores/react';
import { authStore } from '~/lib/stores/auth';
import { motion } from 'framer-motion';
const EXAMPLE_PROMPTS = [
{ text: 'Create a mobile app about bolt.diy' },
{ text: 'Build a todo app in React using Tailwind' },
{ text: 'Build a simple blog using Astro' },
{ text: 'Create a cookie consent form using Material UI' },
{ text: 'Make a space invaders game' },
{ text: 'Make a Tic Tac Toe game in html, css and js only' },
];
interface WelcomeMessageProps {
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
}
export function WelcomeMessage({ sendMessage }: WelcomeMessageProps) {
const authState = useStore(authStore);
const timeOfDay = new Date().getHours();
const getGreeting = () => {
if (timeOfDay < 12) {
return 'Good morning';
}
if (timeOfDay < 17) {
return 'Good afternoon';
}
return 'Good evening';
};
return (
<div className="relative flex flex-col gap-6 w-full max-w-3xl mx-auto mt-8">
{/* Personalized Greeting */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center"
>
<h1 className="text-3xl font-bold text-bolt-elements-textPrimary mb-2">
{getGreeting()}, {authState.user?.firstName || 'Developer'}!
</h1>
<p className="text-lg text-bolt-elements-textSecondary">What would you like to build today?</p>
</motion.div>
{/* Example Prompts */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="flex flex-col gap-3"
>
<p className="text-sm text-bolt-elements-textTertiary text-center">Try one of these examples to get started:</p>
<div className="flex flex-wrap justify-center gap-2">
{EXAMPLE_PROMPTS.map((examplePrompt, index) => (
<motion.button
key={index}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0.3 + index * 0.05 }}
onClick={(event) => sendMessage?.(event, examplePrompt.text)}
className="border border-bolt-elements-borderColor rounded-full bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary px-3 py-1 text-xs transition-all hover:scale-105"
>
{examplePrompt.text}
</motion.button>
))}
</div>
</motion.div>
{/* User Stats */}
{authState.user && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="text-center text-xs text-bolt-elements-textTertiary"
>
<p>
Logged in as{' '}
<span className="text-bolt-elements-textSecondary font-medium">@{authState.user.username}</span>
</p>
</motion.div>
)}
</div>
);
}

View File

@@ -1,29 +1,277 @@
import { useState } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useStore } from '@nanostores/react';
import { netlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { isGitLabConnected } from '~/lib/stores/gitlabConnection';
import { workbenchStore } from '~/lib/stores/workbench';
import { streamingState } from '~/lib/stores/streaming';
import { DeployDialog } from './DeployDialog';
import { classNames } from '~/utils/classNames';
import { useState } from 'react';
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
import { useGitHubDeploy } from '~/components/deploy/GitHubDeploy.client';
import { useGitLabDeploy } from '~/components/deploy/GitLabDeploy.client';
import { GitHubDeploymentDialog } from '~/components/deploy/GitHubDeploymentDialog';
import { GitLabDeploymentDialog } from '~/components/deploy/GitLabDeploymentDialog';
export const DeployButton = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
interface DeployButtonProps {
onVercelDeploy?: () => Promise<void>;
onNetlifyDeploy?: () => Promise<void>;
onGitHubDeploy?: () => Promise<void>;
onGitLabDeploy?: () => Promise<void>;
}
export const DeployButton = ({
onVercelDeploy,
onNetlifyDeploy,
onGitHubDeploy,
onGitLabDeploy,
}: DeployButtonProps) => {
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
const gitlabIsConnected = useStore(isGitLabConnected);
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false);
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'github' | 'gitlab' | null>(null);
const isStreaming = useStore(streamingState);
const { handleVercelDeploy } = useVercelDeploy();
const { handleNetlifyDeploy } = useNetlifyDeploy();
const { handleGitHubDeploy } = useGitHubDeploy();
const { handleGitLabDeploy } = useGitLabDeploy();
const [showGitHubDeploymentDialog, setShowGitHubDeploymentDialog] = useState(false);
const [showGitLabDeploymentDialog, setShowGitLabDeploymentDialog] = useState(false);
const [githubDeploymentFiles, setGithubDeploymentFiles] = useState<Record<string, string> | null>(null);
const [gitlabDeploymentFiles, setGitlabDeploymentFiles] = useState<Record<string, string> | null>(null);
const [githubProjectName, setGithubProjectName] = useState('');
const [gitlabProjectName, setGitlabProjectName] = useState('');
const handleVercelDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('vercel');
try {
if (onVercelDeploy) {
await onVercelDeploy();
} else {
await handleVercelDeploy();
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
const handleNetlifyDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('netlify');
try {
if (onNetlifyDeploy) {
await onNetlifyDeploy();
} else {
await handleNetlifyDeploy();
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
const handleGitHubDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('github');
try {
if (onGitHubDeploy) {
await onGitHubDeploy();
} else {
const result = await handleGitHubDeploy();
if (result && result.success && result.files) {
setGithubDeploymentFiles(result.files);
setGithubProjectName(result.projectName);
setShowGitHubDeploymentDialog(true);
}
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
const handleGitLabDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('gitlab');
try {
if (onGitLabDeploy) {
await onGitLabDeploy();
} else {
const result = await handleGitLabDeploy();
if (result && result.success && result.files) {
setGitlabDeploymentFiles(result.files);
setGitlabProjectName(result.projectName);
setShowGitLabDeploymentDialog(true);
}
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
return (
<>
<button
onClick={() => setIsDialogOpen(true)}
disabled={!activePreview || isStreaming}
className="px-4 py-1.5 rounded-lg bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2 text-sm font-medium"
title="Deploy your project"
>
<span className="i-ph:rocket-launch text-lg" />
Deploy
</button>
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden text-sm">
<DropdownMenu.Root>
<DropdownMenu.Trigger
disabled={isDeploying || !activePreview || isStreaming}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
<span className={classNames('i-ph:caret-down transition-transform')} />
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'z-[250]',
'bg-bolt-elements-background-depth-2',
'rounded-lg shadow-lg',
'border border-bolt-elements-borderColor',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !netlifyConn.user,
},
)}
disabled={isDeploying || !activePreview || !netlifyConn.user}
onClick={handleNetlifyDeployClick}
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<span className="mx-auto">
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
</span>
{netlifyConn.user && <NetlifyDeploymentLink />}
</DropdownMenu.Item>
<DeployDialog isOpen={isDialogOpen} onClose={() => setIsDialogOpen(false)} />
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !vercelConn.user,
},
)}
disabled={isDeploying || !activePreview || !vercelConn.user}
onClick={handleVercelDeployClick}
>
<img
className="w-5 h-5 bg-black p-1 rounded"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/vercel/white"
alt="vercel"
/>
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
{vercelConn.user && <VercelDeploymentLink />}
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview,
},
)}
disabled={isDeploying || !activePreview}
onClick={handleGitHubDeployClick}
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/github"
alt="github"
/>
<span className="mx-auto">Deploy to GitHub</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !gitlabIsConnected,
},
)}
disabled={isDeploying || !activePreview || !gitlabIsConnected}
onClick={handleGitLabDeployClick}
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/gitlab"
alt="gitlab"
/>
<span className="mx-auto">{!gitlabIsConnected ? 'No GitLab Account Connected' : 'Deploy to GitLab'}</span>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2 opacity-60 cursor-not-allowed"
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/cloudflare"
alt="cloudflare"
/>
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/* GitHub Deployment Dialog */}
{showGitHubDeploymentDialog && githubDeploymentFiles && (
<GitHubDeploymentDialog
isOpen={showGitHubDeploymentDialog}
onClose={() => setShowGitHubDeploymentDialog(false)}
projectName={githubProjectName}
files={githubDeploymentFiles}
/>
)}
{/* GitLab Deployment Dialog */}
{showGitLabDeploymentDialog && gitlabDeploymentFiles && (
<GitLabDeploymentDialog
isOpen={showGitLabDeploymentDialog}
onClose={() => setShowGitLabDeploymentDialog(false)}
projectName={gitlabProjectName}
files={gitlabDeploymentFiles}
/>
)}
</>
);
};

View File

@@ -1,466 +0,0 @@
import React, { useState } from 'react';
import * as RadixDialog from '@radix-ui/react-dialog';
import { Dialog, DialogTitle, DialogDescription } from '~/components/ui/Dialog';
import { useStore } from '@nanostores/react';
import { netlifyConnection, updateNetlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { useNetlifyDeploy } from './NetlifyDeploy.client';
import { useVercelDeploy } from './VercelDeploy.client';
import { useGitHubDeploy } from './GitHubDeploy.client';
import { GitHubDeploymentDialog } from './GitHubDeploymentDialog';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
interface DeployDialogProps {
isOpen: boolean;
onClose: () => void;
}
interface DeployProvider {
id: 'netlify' | 'vercel' | 'github' | 'cloudflare';
name: string;
iconClass: string;
iconColor?: string;
connected: boolean;
comingSoon?: boolean;
description: string;
features: string[];
}
const NetlifyConnectForm: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
const [token, setToken] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const handleConnect = async () => {
if (!token.trim()) {
toast.error('Please enter your Netlify API token');
return;
}
setIsConnecting(true);
try {
// Validate token with Netlify API
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Invalid token or authentication failed');
}
const userData = (await response.json()) as any;
// Update the connection store
updateNetlifyConnection({
user: userData,
token,
});
toast.success(`Connected to Netlify as ${userData.email || userData.name || 'User'}`);
onSuccess();
} catch (error) {
console.error('Netlify connection error:', error);
toast.error('Failed to connect to Netlify. Please check your token.');
} finally {
setIsConnecting(false);
}
};
return (
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-bolt-elements-borderColor scrollbar-track-transparent">
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-2">Connect to Netlify</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
To deploy your project to Netlify, you need to connect your account using a Personal Access Token.
</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-1">Personal Access Token</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your Netlify API token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent',
'disabled:opacity-50',
)}
disabled={isConnecting}
/>
</div>
<div className="flex items-center justify-between">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-accent-500 hover:text-accent-600 inline-flex items-center gap-1"
>
Get your token from Netlify
<span className="i-ph:arrow-square-out text-xs" />
</a>
</div>
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-3 space-y-2">
<p className="text-xs text-bolt-elements-textSecondary font-medium">How to get your token:</p>
<ol className="text-xs text-bolt-elements-textSecondary space-y-1 list-decimal list-inside">
<li>Go to your Netlify account settings</li>
<li>Navigate to "Applications" "Personal access tokens"</li>
<li>Click "New access token"</li>
<li>Give it a descriptive name (e.g., "bolt.diy deployment")</li>
<li>Copy the token and paste it here</li>
</ol>
</div>
<div className="flex gap-3">
<button
onClick={handleConnect}
disabled={isConnecting || !token.trim()}
className={classNames(
'flex-1 px-4 py-2 rounded-lg font-medium transition-all',
'bg-accent-500 text-white',
'hover:bg-accent-600',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center justify-center gap-2',
)}
>
{isConnecting ? (
<>
<span className="i-svg-spinners:3-dots-scale" />
Connecting...
</>
) : (
<>
<span className="i-ph:plug-charging" />
Connect Account
</>
)}
</button>
</div>
</div>
</div>
);
};
export const DeployDialog: React.FC<DeployDialogProps> = ({ isOpen, onClose }) => {
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
const [selectedProvider, setSelectedProvider] = useState<'netlify' | 'vercel' | 'github' | null>(null);
const [isDeploying, setIsDeploying] = useState(false);
const [showGitHubDialog, setShowGitHubDialog] = useState(false);
const [githubFiles, setGithubFiles] = useState<Record<string, string> | null>(null);
const [githubProjectName, setGithubProjectName] = useState('');
const { handleNetlifyDeploy } = useNetlifyDeploy();
const { handleVercelDeploy } = useVercelDeploy();
const { handleGitHubDeploy } = useGitHubDeploy();
const providers: DeployProvider[] = [
{
id: 'netlify',
name: 'Netlify',
iconClass: 'i-simple-icons:netlify',
iconColor: 'text-[#00C7B7]',
connected: !!netlifyConn.user,
description: 'Deploy your site with automatic SSL, global CDN, and continuous deployment',
features: [
'Automatic SSL certificates',
'Global CDN',
'Instant rollbacks',
'Deploy previews',
'Form handling',
'Serverless functions',
],
},
{
id: 'vercel',
name: 'Vercel',
iconClass: 'i-simple-icons:vercel',
connected: !!vercelConn.user,
description: 'Deploy with the platform built for frontend developers',
features: [
'Zero-config deployments',
'Edge Functions',
'Analytics',
'Web Vitals monitoring',
'Preview deployments',
'Automatic HTTPS',
],
},
{
id: 'github',
name: 'GitHub',
iconClass: 'i-simple-icons:github',
connected: true, // GitHub doesn't require separate auth
description: 'Deploy to GitHub Pages or create a repository',
features: [
'Free hosting with GitHub Pages',
'Version control integration',
'Collaborative development',
'Actions & Workflows',
'Issue tracking',
'Pull requests',
],
},
{
id: 'cloudflare',
name: 'Cloudflare Pages',
iconClass: 'i-simple-icons:cloudflare',
iconColor: 'text-[#F38020]',
connected: false,
comingSoon: true,
description: "Deploy on Cloudflare's global network",
features: [
'Unlimited bandwidth',
'DDoS protection',
'Web Analytics',
'Edge Workers',
'Custom domains',
'Automatic builds',
],
},
];
const handleDeploy = async (provider: 'netlify' | 'vercel' | 'github') => {
setIsDeploying(true);
try {
let success = false;
if (provider === 'netlify') {
success = await handleNetlifyDeploy();
} else if (provider === 'vercel') {
success = await handleVercelDeploy();
} else if (provider === 'github') {
const result = await handleGitHubDeploy();
if (result && typeof result === 'object' && result.success && result.files) {
setGithubFiles(result.files);
setGithubProjectName(result.projectName);
setShowGitHubDialog(true);
onClose();
return;
}
success = result && typeof result === 'object' ? result.success : false;
}
if (success) {
toast.success(
`Successfully deployed to ${provider === 'netlify' ? 'Netlify' : provider === 'vercel' ? 'Vercel' : 'GitHub'}`,
);
onClose();
}
} catch (error) {
console.error('Deployment error:', error);
toast.error(
`Failed to deploy to ${provider === 'netlify' ? 'Netlify' : provider === 'vercel' ? 'Vercel' : 'GitHub'}`,
);
} finally {
setIsDeploying(false);
}
};
const renderProviderContent = () => {
if (!selectedProvider) {
return (
<div className="grid gap-4">
{providers.map((provider) => (
<button
key={provider.id}
onClick={() =>
!provider.comingSoon && setSelectedProvider(provider.id as 'netlify' | 'vercel' | 'github')
}
disabled={provider.comingSoon}
className={classNames(
'p-4 rounded-lg border-2 transition-all text-left',
'hover:border-accent-500 hover:bg-bolt-elements-background-depth-2',
provider.comingSoon
? 'border-bolt-elements-borderColor opacity-50 cursor-not-allowed'
: 'border-bolt-elements-borderColor cursor-pointer',
)}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg bg-bolt-elements-background-depth-1 flex items-center justify-center flex-shrink-0">
<span
className={classNames(
provider.iconClass,
provider.iconColor || 'text-bolt-elements-textPrimary',
'text-2xl',
)}
/>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
{provider.connected && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-500">Connected</span>
)}
{provider.comingSoon && (
<span className="text-xs px-2 py-0.5 rounded-full bg-bolt-elements-background-depth-3 text-bolt-elements-textTertiary">
Coming Soon
</span>
)}
</div>
<p className="text-sm text-bolt-elements-textSecondary mb-2">{provider.description}</p>
<div className="flex flex-wrap gap-2">
{provider.features.slice(0, 3).map((feature, index) => (
<span
key={index}
className="text-xs px-2 py-1 rounded bg-bolt-elements-background-depth-1 text-bolt-elements-textTertiary"
>
{feature}
</span>
))}
{provider.features.length > 3 && (
<span className="text-xs px-2 py-1 text-bolt-elements-textTertiary">
+{provider.features.length - 3} more
</span>
)}
</div>
</div>
</div>
</button>
))}
</div>
);
}
const provider = providers.find((p) => p.id === selectedProvider);
if (!provider) {
return null;
}
// If provider is not connected, show connection form
if (!provider.connected) {
if (selectedProvider === 'netlify') {
return (
<NetlifyConnectForm
onSuccess={() => {
handleDeploy('netlify');
}}
/>
);
}
// Add Vercel connection form here if needed
return <div>Vercel connection form coming soon...</div>;
}
// If connected, show deployment confirmation
return (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-bolt-elements-background-depth-2 rounded-lg">
<span
className={classNames(
provider.iconClass,
provider.iconColor || 'text-bolt-elements-textPrimary',
'text-3xl',
)}
/>
<div className="flex-1">
<h3 className="font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
<p className="text-sm text-bolt-elements-textSecondary">Ready to deploy to your {provider.name} account</p>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-500">Connected</span>
</div>
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 space-y-3">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Deployment Features:</h4>
<ul className="space-y-2">
{provider.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-bolt-elements-textSecondary">
<span className="i-ph:check-circle text-green-500 mt-0.5" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
<div className="flex gap-3">
<button
onClick={() => setSelectedProvider(null)}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2"
>
Back
</button>
<button
onClick={() => handleDeploy(selectedProvider as 'netlify' | 'vercel' | 'github')}
disabled={isDeploying}
className={classNames(
'flex-1 px-4 py-2 rounded-lg font-medium transition-all',
'bg-accent-500 text-white',
'hover:bg-accent-600',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center justify-center gap-2',
)}
>
{isDeploying ? (
<>
<span className="i-svg-spinners:3-dots-scale" />
Deploying...
</>
) : (
<>
<span className="i-ph:rocket-launch" />
Deploy Now
</>
)}
</button>
</div>
</div>
);
};
return (
<>
<RadixDialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog className="max-w-2xl max-h-[90vh] flex flex-col">
<div className="p-6 flex flex-col max-h-[90vh]">
<div className="flex-shrink-0">
<DialogTitle className="text-xl font-bold mb-1">Deploy Your Project</DialogTitle>
<DialogDescription className="mb-6">
Choose a deployment platform to publish your project to the web
</DialogDescription>
</div>
<div className="flex-1 overflow-y-auto min-h-0 pr-2 -mr-2 scrollbar-thin scrollbar-thumb-bolt-elements-borderColor scrollbar-track-transparent hover:scrollbar-thumb-bolt-elements-textTertiary">
{renderProviderContent()}
</div>
{!selectedProvider && (
<div className="flex-shrink-0 mt-6 pt-6 border-t border-bolt-elements-borderColor">
<button
onClick={onClose}
className="w-full px-4 py-2 rounded-lg border border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2"
>
Cancel
</button>
</div>
)}
</div>
</Dialog>
</RadixDialog.Root>
{/* GitHub Deployment Dialog */}
{showGitHubDialog && githubFiles && (
<GitHubDeploymentDialog
isOpen={showGitHubDialog}
onClose={() => setShowGitHubDialog(false)}
projectName={githubProjectName}
files={githubFiles}
/>
)}
</>
);
};

View File

@@ -1,210 +0,0 @@
/**
* Enhanced Deploy Button with Quick Deploy Option
* Contributed by Keoma Wright
*
* This component provides both authenticated and quick deployment options
*/
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useStore } from '@nanostores/react';
import { netlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { workbenchStore } from '~/lib/stores/workbench';
import { streamingState } from '~/lib/stores/streaming';
import { classNames } from '~/utils/classNames';
import { useState } from 'react';
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
import { QuickNetlifyDeploy } from '~/components/deploy/QuickNetlifyDeploy.client';
import * as Dialog from '@radix-ui/react-dialog';
interface EnhancedDeployButtonProps {
onVercelDeploy?: () => Promise<void>;
onNetlifyDeploy?: () => Promise<void>;
}
export const EnhancedDeployButton = ({ onVercelDeploy, onNetlifyDeploy }: EnhancedDeployButtonProps) => {
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false);
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'quick' | null>(null);
const [showQuickDeploy, setShowQuickDeploy] = useState(false);
const isStreaming = useStore(streamingState);
const { handleVercelDeploy } = useVercelDeploy();
const { handleNetlifyDeploy } = useNetlifyDeploy();
const handleVercelDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('vercel');
try {
if (onVercelDeploy) {
await onVercelDeploy();
} else {
await handleVercelDeploy();
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
const handleNetlifyDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('netlify');
try {
if (onNetlifyDeploy) {
await onNetlifyDeploy();
} else {
await handleNetlifyDeploy();
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
return (
<>
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden text-sm">
<DropdownMenu.Root>
<DropdownMenu.Trigger
disabled={isDeploying || !activePreview || isStreaming}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
{isDeploying ? `Deploying${deployingTo ? ` to ${deployingTo}` : ''}...` : 'Deploy'}
<span className={classNames('i-ph:caret-down transition-transform')} />
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'z-[250]',
'bg-bolt-elements-background-depth-2',
'rounded-lg shadow-lg',
'border border-bolt-elements-borderColor',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
{/* Quick Deploy Option - Always Available */}
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview,
},
)}
disabled={isDeploying || !activePreview}
onClick={() => setShowQuickDeploy(true)}
>
<div className="relative">
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
alt="Netlify Quick Deploy"
/>
<span className="absolute -top-1 -right-1 bg-green-500 text-white text-[8px] px-1 rounded">NEW</span>
</div>
<span className="mx-auto font-medium">Quick Deploy to Netlify (No Login)</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-bolt-elements-borderColor my-1" />
{/* Authenticated Netlify Deploy */}
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !netlifyConn.user,
},
)}
disabled={isDeploying || !activePreview || !netlifyConn.user}
onClick={handleNetlifyDeployClick}
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<span className="mx-auto">
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
</span>
{netlifyConn.user && <NetlifyDeploymentLink />}
</DropdownMenu.Item>
{/* Vercel Deploy */}
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !vercelConn.user,
},
)}
disabled={isDeploying || !activePreview || !vercelConn.user}
onClick={handleVercelDeployClick}
>
<img
className="w-5 h-5 bg-black p-1 rounded"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/vercel/white"
alt="vercel"
/>
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
{vercelConn.user && <VercelDeploymentLink />}
</DropdownMenu.Item>
{/* Cloudflare - Coming Soon */}
<DropdownMenu.Item
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2 opacity-60 cursor-not-allowed"
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/cloudflare"
alt="cloudflare"
/>
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/* Quick Deploy Dialog */}
<Dialog.Root open={showQuickDeploy} onOpenChange={setShowQuickDeploy}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-[999] animate-in fade-in-0" />
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[1000] w-full max-w-2xl animate-in fade-in-0 zoom-in-95">
<div className="bg-bolt-elements-background rounded-lg shadow-xl border border-bolt-elements-borderColor">
<div className="flex items-center justify-between p-4 border-b border-bolt-elements-borderColor">
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Quick Deploy to Netlify</h2>
<Dialog.Close className="p-1 rounded hover:bg-bolt-elements-item-backgroundActive transition-colors">
<span className="i-ph:x text-lg text-bolt-elements-textSecondary" />
</Dialog.Close>
</div>
<div className="p-4">
<QuickNetlifyDeploy />
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</>
);
};

View File

@@ -1,362 +0,0 @@
/**
* Quick Netlify Deployment Component
* Contributed by Keoma Wright
*
* This component provides a streamlined one-click deployment to Netlify
* with automatic build detection and configuration.
*/
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { workbenchStore } from '~/lib/stores/workbench';
import { webcontainer } from '~/lib/webcontainer';
import { path } from '~/utils/path';
import { chatId } from '~/lib/persistence/useChatHistory';
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
interface QuickDeployConfig {
framework?: 'react' | 'vue' | 'angular' | 'svelte' | 'next' | 'nuxt' | 'gatsby' | 'static';
buildCommand?: string;
outputDirectory?: string;
nodeVersion?: string;
}
export function QuickNetlifyDeploy() {
const [isDeploying, setIsDeploying] = useState(false);
const [deployUrl, setDeployUrl] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const currentChatId = useStore(chatId);
const detectFramework = async (): Promise<QuickDeployConfig> => {
try {
const container = await webcontainer;
// Read package.json to detect framework
let packageJson: any = {};
try {
const packageContent = await container.fs.readFile('/package.json', 'utf-8');
packageJson = JSON.parse(packageContent);
} catch {
console.log('No package.json found, assuming static site');
return {
framework: 'static',
buildCommand: '',
outputDirectory: '/',
nodeVersion: '18',
};
}
const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
const scripts = packageJson.scripts || {};
// Detect framework based on dependencies
const config: QuickDeployConfig = {
nodeVersion: '18',
};
if (deps.next) {
config.framework = 'next';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = '.next';
} else if (deps.nuxt || deps.nuxt3) {
config.framework = 'nuxt';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = '.output/public';
} else if (deps.gatsby) {
config.framework = 'gatsby';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = 'public';
} else if (deps['@angular/core']) {
config.framework = 'angular';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = 'dist';
} else if (deps.vue) {
config.framework = 'vue';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = 'dist';
} else if (deps.svelte) {
config.framework = 'svelte';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = 'public';
} else if (deps.react) {
config.framework = 'react';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = 'build';
// Check for Vite
if (deps.vite) {
config.outputDirectory = 'dist';
}
} else {
config.framework = 'static';
config.buildCommand = scripts.build || '';
config.outputDirectory = '/';
}
return config;
} catch (error) {
console.error('Error detecting framework:', error);
return {
framework: 'static',
buildCommand: '',
outputDirectory: '/',
nodeVersion: '18',
};
}
};
const handleQuickDeploy = async (): Promise<string | null> => {
if (!currentChatId) {
toast.error('No active project found');
return null;
}
try {
setIsDeploying(true);
setDeployUrl(null);
const artifact = workbenchStore.firstArtifact;
if (!artifact) {
throw new Error('No active project found');
}
// Detect framework and configuration
const config = await detectFramework();
toast.info(`Detected ${config.framework || 'static'} project. Starting deployment...`);
// Create deployment artifact for visual feedback
const deploymentId = `quick-deploy-${Date.now()}`;
workbenchStore.addArtifact({
id: deploymentId,
messageId: deploymentId,
title: 'Quick Netlify Deployment',
type: 'standalone',
});
const deployArtifact = workbenchStore.artifacts.get()[deploymentId];
// Build the project if needed
if (config.buildCommand) {
deployArtifact.runner.handleDeployAction('building', 'running', { source: 'netlify' });
const actionId = 'build-' + Date.now();
const actionData: ActionCallbackData = {
messageId: 'quick-netlify-build',
artifactId: artifact.id,
actionId,
action: {
type: 'build' as const,
content: config.buildCommand,
},
};
artifact.runner.addAction(actionData);
await artifact.runner.runAction(actionData);
if (!artifact.runner.buildOutput) {
deployArtifact.runner.handleDeployAction('building', 'failed', {
error: 'Build failed. Check the terminal for details.',
source: 'netlify',
});
throw new Error('Build failed');
}
}
// Prepare deployment
deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'netlify' });
const container = await webcontainer;
// Determine the output directory
let outputPath = config.outputDirectory || '/';
if (artifact.runner.buildOutput && artifact.runner.buildOutput.path) {
outputPath = artifact.runner.buildOutput.path.replace('/home/project', '');
}
// Collect files for deployment
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
const files: Record<string, string> = {};
try {
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
// Skip node_modules and other build artifacts
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.cache') {
continue;
}
if (entry.isFile()) {
try {
const content = await container.fs.readFile(fullPath, 'utf-8');
const deployPath = fullPath.replace(outputPath, '');
files[deployPath] = content;
} catch (e) {
console.warn(`Could not read file ${fullPath}:`, e);
}
} else if (entry.isDirectory()) {
const subFiles = await getAllFiles(fullPath);
Object.assign(files, subFiles);
}
}
} catch (e) {
console.error(`Error reading directory ${dirPath}:`, e);
}
return files;
}
const fileContents = await getAllFiles(outputPath);
// Create netlify.toml configuration
const netlifyConfig = `
[build]
publish = "${config.outputDirectory || '/'}"
${config.buildCommand ? `command = "${config.buildCommand}"` : ''}
[build.environment]
NODE_VERSION = "${config.nodeVersion}"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
X-Content-Type-Options = "nosniff"
`;
fileContents['/netlify.toml'] = netlifyConfig;
// Deploy to Netlify using the quick deploy endpoint
const response = await fetch('/api/netlify-quick-deploy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: fileContents,
chatId: currentChatId,
framework: config.framework,
}),
});
const data = (await response.json()) as { success: boolean; url?: string; siteId?: string; error?: string };
if (!response.ok || !data.success) {
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
error: data.error || 'Deployment failed',
source: 'netlify',
});
throw new Error(data.error || 'Deployment failed');
}
// Deployment successful
setDeployUrl(data.url || null);
deployArtifact.runner.handleDeployAction('complete', 'complete', {
url: data.url || '',
source: 'netlify',
});
toast.success('Deployment successful! Your app is live.');
// Store deployment info
if (data.siteId) {
localStorage.setItem(`netlify-quick-site-${currentChatId}`, data.siteId);
}
return data.url || null;
} catch (error) {
console.error('Quick deploy error:', error);
toast.error(error instanceof Error ? error.message : 'Deployment failed');
return null;
} finally {
setIsDeploying(false);
}
};
return (
<div className="flex flex-col gap-4 p-4 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<img className="w-5 h-5" src="https://cdn.simpleicons.org/netlify" alt="Netlify" />
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Quick Deploy to Netlify</h3>
</div>
{deployUrl && (
<a
href={deployUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-bolt-elements-link-text hover:text-bolt-elements-link-textHover underline"
>
View Live Site
</a>
)}
</div>
<p className="text-sm text-bolt-elements-textSecondary">
Deploy your project to Netlify instantly with automatic framework detection and configuration.
</p>
<button
onClick={handleQuickDeploy}
disabled={isDeploying}
className="px-6 py-3 rounded-lg bg-accent-500 text-white font-medium hover:bg-accent-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center gap-2"
>
{isDeploying ? (
<>
<span className="i-ph:spinner-gap animate-spin w-5 h-5" />
Deploying...
</>
) : (
<>
<span className="i-ph:rocket-launch w-5 h-5" />
Deploy Now
</>
)}
</button>
{deployUrl && (
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<p className="text-sm text-green-600 dark:text-green-400">
Your app is live at:{' '}
<a href={deployUrl} target="_blank" rel="noopener noreferrer" className="underline font-medium">
{deployUrl}
</a>
</p>
</div>
)}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-sm text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors flex items-center gap-1"
>
<span className={`i-ph:caret-right transform transition-transform ${showAdvanced ? 'rotate-90' : ''}`} />
Advanced Options
</button>
{showAdvanced && (
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-2 text-sm text-bolt-elements-textSecondary space-y-2">
<p> Automatic framework detection (React, Vue, Next.js, etc.)</p>
<p> Smart build command configuration</p>
<p> Optimized output directory selection</p>
<p> SSL/HTTPS enabled by default</p>
<p> Global CDN distribution</p>
</div>
)}
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { classNames } from '~/utils/classNames';
import { HeaderActionButtons } from './HeaderActionButtons.client';
import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
export function Header({ children }: { children?: React.ReactNode }) {
export function Header() {
const chat = useStore(chatStore);
return (
@@ -37,8 +37,6 @@ export function Header({ children }: { children?: React.ReactNode }) {
</ClientOnly>
</>
)}
{!chat.started && <div className="flex-1" />}
{children}
</header>
);
}

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useStore } from '@nanostores/react';
import { workbenchStore } from '~/lib/stores/workbench';
import { DeployButton } from '~/components/deploy/DeployButton';
import { MultiUserToggle } from '~/components/multiuser/MultiUserToggle';
interface HeaderActionButtonsProps {
chatStarted: boolean;
@@ -16,10 +15,7 @@ export function HeaderActionButtons({ chatStarted: _chatStarted }: HeaderActionB
const shouldShowButtons = activePreview;
return (
<div className="flex items-center gap-2">
{/* Multi-User Sessions Toggle (Bolt.gives Exclusive) */}
<MultiUserToggle />
<div className="flex items-center gap-1">
{/* Deploy Button */}
{shouldShowButtons && <DeployButton />}

View File

@@ -1,176 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from '@remix-run/react';
import { useStore } from '@nanostores/react';
import { authStore, logout } from '~/lib/stores/auth';
import { motion, AnimatePresence } from 'framer-motion';
import { classNames } from '~/utils/classNames';
export function UserMenu() {
const navigate = useNavigate();
const authState = useStore(authStore);
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleLogout = async () => {
await logout();
navigate('/auth');
};
const handleManageUsers = () => {
setIsOpen(false);
navigate('/admin/users');
};
const handleSettings = () => {
setIsOpen(false);
// Open settings modal or navigate to settings
};
if (!authState.isAuthenticated || !authState.user) {
return null;
}
return (
<div ref={menuRef} className="relative">
{/* User Avatar Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={classNames(
'flex items-center gap-2 px-3 py-2 rounded-lg',
'hover:bg-bolt-elements-background-depth-2',
'transition-colors',
)}
>
<div className="w-8 h-8 rounded-full bg-bolt-elements-background-depth-2 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
{authState.user.avatar ? (
<img src={authState.user.avatar} alt={authState.user.firstName} className="w-full h-full object-cover" />
) : (
<span className="text-sm font-medium text-bolt-elements-textPrimary">
{authState.user.firstName[0].toUpperCase()}
</span>
)}
</div>
<div className="text-left hidden sm:block">
<p className="text-sm font-medium text-bolt-elements-textPrimary">{authState.user.firstName}</p>
<p className="text-xs text-bolt-elements-textSecondary">@{authState.user.username}</p>
</div>
<span
className={classNames(
'i-ph:caret-down text-bolt-elements-textSecondary transition-transform',
isOpen ? 'rotate-180' : '',
)}
/>
</button>
{/* Dropdown Menu */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className={classNames(
'absolute right-0 mt-2 w-64',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'rounded-lg shadow-lg',
'overflow-hidden',
'z-50',
)}
>
{/* User Info */}
<div className="p-4 border-b border-bolt-elements-borderColor">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-bolt-elements-background-depth-2 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
{authState.user.avatar ? (
<img
src={authState.user.avatar}
alt={authState.user.firstName}
className="w-full h-full object-cover"
/>
) : (
<span className="text-lg font-medium text-bolt-elements-textPrimary">
{authState.user.firstName[0].toUpperCase()}
</span>
)}
</div>
<div className="flex-1">
<p className="font-medium text-bolt-elements-textPrimary">{authState.user.firstName}</p>
<p className="text-sm text-bolt-elements-textSecondary">@{authState.user.username}</p>
</div>
</div>
</div>
{/* Menu Items */}
<div className="py-2">
<button
onClick={handleSettings}
className={classNames(
'w-full px-4 py-2 text-left',
'text-sm text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-2',
'transition-colors',
'flex items-center gap-3',
)}
>
<span className="i-ph:gear text-lg" />
<span>Settings</span>
</button>
<button
onClick={handleManageUsers}
className={classNames(
'w-full px-4 py-2 text-left',
'text-sm text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-2',
'transition-colors',
'flex items-center gap-3',
)}
>
<span className="i-ph:users text-lg" />
<span>Manage Users</span>
</button>
<div className="my-1 border-t border-bolt-elements-borderColor" />
<button
onClick={handleLogout}
className={classNames(
'w-full px-4 py-2 text-left',
'text-sm text-red-500',
'hover:bg-red-500/10',
'transition-colors',
'flex items-center gap-3',
)}
>
<span className="i-ph:sign-out text-lg" />
<span>Sign Out</span>
</button>
</div>
{/* Footer */}
<div className="px-4 py-2 bg-bolt-elements-background-depth-2 border-t border-bolt-elements-borderColor">
<p className="text-xs text-bolt-elements-textTertiary">
Member since {new Date(authState.user.createdAt).toLocaleDateString()}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,72 +0,0 @@
import React, { useState, useCallback } from 'react';
import { ImportProjectDialog } from './ImportProjectDialog';
import { workbenchStore } from '~/lib/stores/workbench';
import { toast } from 'react-toastify';
import { useHotkeys } from 'react-hotkeys-hook';
export const ImportProjectButton: React.FC = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Add keyboard shortcut
useHotkeys('ctrl+shift+i, cmd+shift+i', (e) => {
e.preventDefault();
setIsDialogOpen(true);
});
const handleImport = useCallback(async (files: Map<string, string>) => {
try {
console.log('[ImportProject] Starting import of', files.size, 'files');
// Add files to workbench
for (const [path, content] of files.entries()) {
// Ensure path starts with /
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
console.log('[ImportProject] Adding file:', normalizedPath);
// Add file to workbench file system
workbenchStore.files.setKey(normalizedPath, {
type: 'file',
content,
isBinary: false,
});
}
// Open the first file in the editor if any
const firstFile = Array.from(files.keys())[0];
if (firstFile) {
const normalizedPath = firstFile.startsWith('/') ? firstFile : `/${firstFile}`;
workbenchStore.setSelectedFile(normalizedPath);
}
toast.success(`Successfully imported ${files.size} files`, {
position: 'bottom-right',
autoClose: 3000,
});
setIsDialogOpen(false);
} catch (error) {
console.error('[ImportProject] Import failed:', error);
toast.error('Failed to import project files', {
position: 'bottom-right',
autoClose: 5000,
});
}
}, []);
return (
<>
<button
onClick={() => setIsDialogOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text transition-colors duration-200"
title="Import existing project (Ctrl+Shift+I)"
>
<div className="i-ph:upload-simple text-lg" />
<span className="text-sm font-medium">Import Project</span>
</button>
<ImportProjectDialog isOpen={isDialogOpen} onClose={() => setIsDialogOpen(false)} onImport={handleImport} />
</>
);
};

View File

@@ -1,567 +0,0 @@
import React, { useState, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import JSZip from 'jszip';
import { toast } from 'react-toastify';
import * as RadixDialog from '@radix-ui/react-dialog';
import { Dialog, DialogTitle, DialogDescription } from '~/components/ui/Dialog';
import { classNames } from '~/utils/classNames';
interface ImportProjectDialogProps {
isOpen: boolean;
onClose: () => void;
onImport?: (files: Map<string, string>) => void;
}
interface FileStructure {
[path: string]: string | ArrayBuffer;
}
interface ImportStats {
totalFiles: number;
totalSize: number;
fileTypes: Map<string, number>;
directories: Set<string>;
}
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB max per file
const MAX_TOTAL_SIZE = 200 * 1024 * 1024; // 200MB max total
const IGNORED_PATTERNS = [
/node_modules\//,
/\.git\//,
/\.next\//,
/dist\//,
/build\//,
/\.cache\//,
/\.vscode\//,
/\.idea\//,
/\.DS_Store$/,
/Thumbs\.db$/,
/\.env\.local$/,
/\.env\.production$/,
];
const BINARY_EXTENSIONS = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.svg',
'.ico',
'.pdf',
'.zip',
'.tar',
'.gz',
'.rar',
'.mp3',
'.mp4',
'.avi',
'.mov',
'.exe',
'.dll',
'.so',
'.dylib',
'.woff',
'.woff2',
'.ttf',
'.eot',
];
export const ImportProjectDialog: React.FC<ImportProjectDialogProps> = ({ isOpen, onClose, onImport }) => {
const [isDragging, setIsDragging] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [importStats, setImportStats] = useState<ImportStats | null>(null);
const [selectedFiles, setSelectedFiles] = useState<FileStructure>({});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);
const resetState = useCallback(() => {
setSelectedFiles({});
setImportStats(null);
setImportProgress(0);
setErrorMessage(null);
setIsProcessing(false);
}, []);
const shouldIgnoreFile = (path: string): boolean => {
return IGNORED_PATTERNS.some((pattern) => pattern.test(path));
};
const isBinaryFile = (filename: string): boolean => {
return BINARY_EXTENSIONS.some((ext) => filename.toLowerCase().endsWith(ext));
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(2)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
const processZipFile = async (file: File): Promise<FileStructure> => {
const zip = new JSZip();
const zipData = await zip.loadAsync(file);
const files: FileStructure = {};
const stats: ImportStats = {
totalFiles: 0,
totalSize: 0,
fileTypes: new Map(),
directories: new Set(),
};
const filePromises: Promise<void>[] = [];
zipData.forEach((relativePath, zipEntry) => {
if (!zipEntry.dir && !shouldIgnoreFile(relativePath)) {
const promise = (async () => {
try {
const content = await zipEntry.async(isBinaryFile(relativePath) ? 'arraybuffer' : 'string');
files[relativePath] = content;
stats.totalFiles++;
// Use a safe method to get uncompressed size
const size = (zipEntry as any)._data?.uncompressedSize || 0;
stats.totalSize += size;
const ext = relativePath.split('.').pop() || 'unknown';
stats.fileTypes.set(ext, (stats.fileTypes.get(ext) || 0) + 1);
const dir = relativePath.substring(0, relativePath.lastIndexOf('/'));
if (dir) {
stats.directories.add(dir);
}
setImportProgress((prev) => Math.min(prev + 100 / Object.keys(zipData.files).length, 100));
} catch (err) {
console.error(`Failed to process ${relativePath}:`, err);
}
})();
filePromises.push(promise);
}
});
await Promise.all(filePromises);
setImportStats(stats);
return files;
};
const processFileList = async (fileList: FileList): Promise<FileStructure> => {
const files: FileStructure = {};
const stats: ImportStats = {
totalFiles: 0,
totalSize: 0,
fileTypes: new Map(),
directories: new Set(),
};
let totalSize = 0;
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
const path = (file as any).webkitRelativePath || file.name;
if (shouldIgnoreFile(path)) {
continue;
}
if (file.size > MAX_FILE_SIZE) {
toast.warning(`Skipping ${file.name}: File too large (${formatFileSize(file.size)})`);
continue;
}
totalSize += file.size;
if (totalSize > MAX_TOTAL_SIZE) {
toast.error('Total size exceeds 200MB limit');
break;
}
try {
const content = await (isBinaryFile(file.name) ? file.arrayBuffer() : file.text());
files[path] = content;
stats.totalFiles++;
stats.totalSize += file.size;
const ext = file.name.split('.').pop() || 'unknown';
stats.fileTypes.set(ext, (stats.fileTypes.get(ext) || 0) + 1);
const dir = path.substring(0, path.lastIndexOf('/'));
if (dir) {
stats.directories.add(dir);
}
setImportProgress(((i + 1) / fileList.length) * 100);
} catch (err) {
console.error(`Failed to read ${file.name}:`, err);
}
}
setImportStats(stats);
return files;
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) {
return;
}
setIsProcessing(true);
setErrorMessage(null);
setImportProgress(0);
try {
let processedFiles: FileStructure = {};
if (files.length === 1 && files[0].name.endsWith('.zip')) {
processedFiles = await processZipFile(files[0]);
} else {
processedFiles = await processFileList(files);
}
if (Object.keys(processedFiles).length === 0) {
toast.warning('No valid files found to import');
setIsProcessing(false);
return;
}
setSelectedFiles(processedFiles);
toast.info(`Ready to import ${Object.keys(processedFiles).length} files`);
} catch (error) {
console.error('Error processing files:', error);
setErrorMessage(error instanceof Error ? error.message : 'Failed to process files');
toast.error('Failed to process files');
} finally {
setIsProcessing(false);
setImportProgress(0);
}
};
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const input = fileInputRef.current;
if (input) {
const dataTransfer = new DataTransfer();
Array.from(files).forEach((file) => dataTransfer.items.add(file));
input.files = dataTransfer.files;
handleFileSelect({ target: input } as any);
}
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget === e.target) {
setIsDragging(false);
}
}, []);
const getFileExtension = (filename: string): string => {
const parts = filename.split('.');
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : 'file';
};
const getFileIcon = (filename: string): string => {
const ext = getFileExtension(filename);
const iconMap: { [key: string]: string } = {
js: 'i-vscode-icons:file-type-js',
jsx: 'i-vscode-icons:file-type-reactjs',
ts: 'i-vscode-icons:file-type-typescript',
tsx: 'i-vscode-icons:file-type-reactts',
css: 'i-vscode-icons:file-type-css',
scss: 'i-vscode-icons:file-type-scss',
html: 'i-vscode-icons:file-type-html',
json: 'i-vscode-icons:file-type-json',
md: 'i-vscode-icons:file-type-markdown',
py: 'i-vscode-icons:file-type-python',
vue: 'i-vscode-icons:file-type-vue',
svg: 'i-vscode-icons:file-type-svg',
git: 'i-vscode-icons:file-type-git',
folder: 'i-vscode-icons:default-folder',
};
return iconMap[ext] || 'i-vscode-icons:default-file';
};
const handleImportClick = useCallback(async () => {
if (Object.keys(selectedFiles).length === 0) {
return;
}
setIsProcessing(true);
try {
const fileMap = new Map<string, string>();
for (const [path, content] of Object.entries(selectedFiles)) {
if (typeof content === 'string') {
fileMap.set(path, content);
} else if (content instanceof ArrayBuffer) {
// Convert ArrayBuffer to base64 string for binary files
const bytes = new Uint8Array(content);
const binary = String.fromCharCode(...bytes);
const base64 = btoa(binary);
fileMap.set(path, base64);
}
}
if (onImport) {
// Use the provided onImport callback
await onImport(fileMap);
}
toast.success(`Successfully imported ${importStats?.totalFiles || 0} files`, {
position: 'bottom-right',
autoClose: 3000,
});
resetState();
onClose();
} catch (error) {
toast.error('Failed to import project', { position: 'bottom-right' });
setErrorMessage(error instanceof Error ? error.message : 'Import failed');
} finally {
setIsProcessing(false);
}
}, [selectedFiles, importStats, onImport, onClose, resetState]);
return (
<RadixDialog.Root open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
<Dialog className="max-w-3xl" showCloseButton={false}>
<div className="p-6">
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<div className="i-ph:upload-duotone text-3xl text-accent-500" />
Import Existing Project
</DialogTitle>
<DialogDescription>
Upload your project files or drag and drop them here. Supports individual files, folders, or ZIP archives.
</DialogDescription>
<div className="mt-6">
<AnimatePresence mode="wait">
{!Object.keys(selectedFiles).length ? (
<motion.div
key="dropzone"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<div
ref={dropZoneRef}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={classNames(
'relative border-2 border-dashed rounded-xl p-12 text-center transition-all duration-200',
isDragging
? 'border-accent-500 bg-accent-500/10 scale-[1.02]'
: 'border-bolt-elements-borderColor hover:border-accent-400/50',
isProcessing ? 'pointer-events-none opacity-50' : '',
)}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".zip,*"
onChange={handleFileSelect}
className="hidden"
{...({ webkitdirectory: 'true', directory: 'true' } as any)}
/>
<div className="space-y-4">
<div className="flex justify-center">
<motion.div
animate={isDragging ? { scale: 1.1, rotate: 5 } : { scale: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 300 }}
className="i-ph:cloud-arrow-up-duotone text-6xl text-accent-500"
/>
</div>
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-2">
{isDragging ? 'Drop your project here' : 'Drag & Drop your project'}
</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Support for folders, multiple files, or ZIP archives
</p>
</div>
<div className="flex gap-3 justify-center">
<button
onClick={() => fileInputRef.current?.click()}
disabled={isProcessing}
className="px-6 py-2.5 bg-accent-500 hover:bg-accent-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Browse Files
</button>
<button
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.zip';
input.onchange = (e) => {
const target = e.target as HTMLInputElement;
if (target.files) {
handleFileSelect({ target } as any);
}
};
input.click();
}}
disabled={isProcessing}
className="px-6 py-2.5 bg-transparent border border-bolt-elements-borderColor hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textPrimary rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Upload ZIP
</button>
</div>
</div>
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center bg-bolt-elements-background-depth-1/80 rounded-xl">
<div className="text-center">
<div className="i-svg-spinners:3-dots-scale text-4xl text-accent-500 mb-2" />
<p className="text-sm text-bolt-elements-textSecondary">
Processing files... {Math.round(importProgress)}%
</p>
</div>
</div>
)}
</div>
{errorMessage && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mt-4 p-4 bg-red-500/10 border border-red-500/20 rounded-lg"
>
<p className="text-sm text-red-400">{errorMessage}</p>
</motion.div>
)}
</motion.div>
) : (
<motion.div
key="preview"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
{importStats && (
<div className="grid grid-cols-3 gap-4 p-4 bg-bolt-elements-item-backgroundActive rounded-lg">
<div>
<p className="text-xs text-bolt-elements-textSecondary">Total Files</p>
<p className="text-lg font-semibold text-bolt-elements-textPrimary">{importStats.totalFiles}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Total Size</p>
<p className="text-lg font-semibold text-bolt-elements-textPrimary">
{formatFileSize(importStats.totalSize)}
</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Directories</p>
<p className="text-lg font-semibold text-bolt-elements-textPrimary">
{importStats.directories.size}
</p>
</div>
</div>
)}
<div className="border border-bolt-elements-borderColor rounded-lg overflow-hidden">
<div className="bg-bolt-elements-background-depth-2 px-4 py-2 border-b border-bolt-elements-borderColor">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Files to Import</h4>
</div>
<div className="max-h-64 overflow-y-auto">
{Object.keys(selectedFiles)
.slice(0, 50)
.map((path, index) => (
<div
key={index}
className="flex items-center gap-2 px-4 py-2 hover:bg-bolt-elements-item-backgroundActive transition-colors"
>
<div className={getFileIcon(path)} />
<span className="text-sm text-bolt-elements-textPrimary truncate">{path}</span>
</div>
))}
{Object.keys(selectedFiles).length > 50 && (
<div className="px-4 py-2 text-sm text-bolt-elements-textSecondary">
... and {Object.keys(selectedFiles).length - 50} more files
</div>
)}
</div>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
resetState();
}}
className="px-4 py-2 text-sm text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
>
Clear Selection
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm border border-bolt-elements-borderColor rounded-lg hover:bg-bolt-elements-item-backgroundActive transition-colors"
>
Cancel
</button>
<button
onClick={handleImportClick}
disabled={isProcessing}
className="px-6 py-2 bg-accent-500 hover:bg-accent-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<>
<span className="i-svg-spinners:3-dots-scale mr-2" />
Importing...
</>
) : (
`Import ${Object.keys(selectedFiles).length} Files`
)}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</Dialog>
</RadixDialog.Root>
);
};

View File

@@ -1,346 +0,0 @@
import React, { useState, useEffect } from 'react';
import { classNames } from '~/utils/classNames';
import { Dialog } from '~/components/ui/Dialog';
import * as RadixDialog from '@radix-ui/react-dialog';
import { Button } from '~/components/ui/Button';
import { toast } from 'react-toastify';
import Cookies from 'js-cookie';
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'developer' | 'viewer' | 'guest';
status: 'active' | 'idle' | 'offline';
lastActivity: string;
avatar?: string;
}
interface Session {
userId: string;
sessionId: string;
startTime: string;
lastActivity: string;
ipAddress: string;
device: string;
}
export const MultiUserSessionManager: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [activeUsers, setActiveUsers] = useState<User[]>([]);
const [sessions, setSessions] = useState<Session[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<'developer' | 'viewer'>('developer');
useEffect(() => {
loadSessionData();
const interval = setInterval(loadSessionData, 5000);
// Refresh every 5 seconds
return () => clearInterval(interval);
}, []);
const loadSessionData = async () => {
try {
// Get current user
const token = Cookies.get('auth_token');
if (token) {
const userResponse = await fetch('/api/auth/verify', {
headers: { Authorization: `Bearer ${token}` },
});
if (userResponse.ok) {
const userData = await userResponse.json();
setCurrentUser(userData as User);
}
}
// Get active users (mock data for demo)
const mockUsers: User[] = [
{
id: '1',
email: 'admin@example.com',
name: 'Admin User',
role: 'admin',
status: 'active',
lastActivity: new Date().toISOString(),
},
{
id: '2',
email: 'dev@example.com',
name: 'Developer',
role: 'developer',
status: 'idle',
lastActivity: new Date(Date.now() - 5 * 60000).toISOString(),
},
];
setActiveUsers(mockUsers);
// Get active sessions (mock data for demo)
const mockSessions: Session[] = [
{
userId: '1',
sessionId: 'session-1',
startTime: new Date(Date.now() - 30 * 60000).toISOString(),
lastActivity: new Date().toISOString(),
ipAddress: '192.168.1.1',
device: 'Chrome on Windows',
},
{
userId: '2',
sessionId: 'session-2',
startTime: new Date(Date.now() - 60 * 60000).toISOString(),
lastActivity: new Date(Date.now() - 5 * 60000).toISOString(),
ipAddress: '192.168.1.2',
device: 'Safari on Mac',
},
];
setSessions(mockSessions);
} catch (error) {
console.error('Failed to load session data:', error);
}
};
const handleInviteUser = async () => {
if (!inviteEmail.trim()) {
toast.error('Please enter an email address');
return;
}
try {
// Send invitation
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: inviteEmail,
role: inviteRole,
action: 'invite',
}),
});
if (response.ok) {
toast.success(`Invitation sent to ${inviteEmail}`);
setInviteEmail('');
} else {
toast.error('Failed to send invitation');
}
} catch (error) {
console.error('Invite error:', error);
toast.error('Failed to send invitation');
}
};
const handleRemoveUser = async (userId: string) => {
if (!window.confirm('Are you sure you want to remove this user?')) {
return;
}
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
});
if (response.ok) {
toast.success('User removed successfully');
loadSessionData();
} else {
toast.error('Failed to remove user');
}
} catch (error) {
console.error('Remove user error:', error);
toast.error('Failed to remove user');
}
};
/**
* const handleTerminateSession = async (_sessionId: string) => {
* if (!window.confirm('Are you sure you want to terminate this session?')) {
* return;
* }
*
* try {
* // Terminate session
* toast.success('Session terminated');
* loadSessionData();
* } catch (error) {
* console.error('Terminate session error:', error);
* toast.error('Failed to terminate session');
* }
* };
*/
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'developer':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'viewer':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'guest':
return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
default:
return 'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'active':
return 'i-ph:circle-fill text-green-400';
case 'idle':
return 'i-ph:circle-fill text-yellow-400';
case 'offline':
return 'i-ph:circle-fill text-gray-400';
default:
return 'i-ph:circle text-gray-400';
}
};
const formatTimeAgo = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diff < 60) {
return 'Just now';
}
if (diff < 3600) {
return `${Math.floor(diff / 60)} min ago`;
}
if (diff < 86400) {
return `${Math.floor(diff / 3600)} hours ago`;
}
return `${Math.floor(diff / 86400)} days ago`;
};
const multiUserEnabled = localStorage.getItem('multiUserEnabled') === 'true';
if (!multiUserEnabled) {
return null;
}
return (
<>
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:bg-bolt-elements-background-depth-2 transition-all"
title="Manage Sessions"
>
<span className="i-ph:users-three text-sm text-bolt-elements-textSecondary" />
<span className="text-xs font-medium text-bolt-elements-textPrimary">{activeUsers.length} Active</span>
</button>
{isOpen && (
<RadixDialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog className="max-w-4xl" onClose={() => setIsOpen(false)}>
<div className="p-6">
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary mb-6">Multi-User Session Manager</h2>
{/* Tabs */}
<div className="flex gap-4 mb-6 border-b border-bolt-elements-borderColor">
<button className="px-4 py-2 text-sm font-medium text-bolt-elements-textPrimary border-b-2 border-blue-500">
Active Users ({activeUsers.length})
</button>
<button className="px-4 py-2 text-sm font-medium text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary">
Sessions ({sessions.length})
</button>
<button className="px-4 py-2 text-sm font-medium text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary">
Invite Users
</button>
</div>
{/* Active Users List */}
<div className="space-y-3 mb-6">
{activeUsers.map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-4 bg-bolt-elements-background-depth-2 rounded-lg"
>
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center">
<span className="text-white font-semibold">{user.name.charAt(0).toUpperCase()}</span>
</div>
{/* User Info */}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-bolt-elements-textPrimary">{user.name}</span>
<span className={classNames('text-xs', getStatusIcon(user.status))} />
<span
className={classNames(
'px-2 py-0.5 text-xs font-medium rounded-full border',
getRoleBadgeColor(user.role),
)}
>
{user.role}
</span>
</div>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-bolt-elements-textSecondary">{user.email}</span>
<span className="text-xs text-bolt-elements-textTertiary">
Active {formatTimeAgo(user.lastActivity)}
</span>
</div>
</div>
</div>
{/* Actions */}
{currentUser?.role === 'admin' && user.id !== currentUser.id && (
<div className="flex items-center gap-2">
<button
onClick={() => handleRemoveUser(user.id)}
className="p-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
title="Remove User"
>
<span className="i-ph:trash text-sm" />
</button>
</div>
)}
</div>
))}
</div>
{/* Invite User Section */}
<div className="p-4 bg-bolt-elements-background-depth-2 rounded-lg">
<h3 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Invite New User</h3>
<div className="flex gap-2">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address"
className="flex-1 px-3 py-1.5 text-sm bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary"
/>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as 'developer' | 'viewer')}
className="px-3 py-1.5 text-sm bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg text-bolt-elements-textPrimary"
>
<option value="developer">Developer</option>
<option value="viewer">Viewer</option>
</select>
<Button
variant="default"
onClick={handleInviteUser}
className="bg-gradient-to-r from-green-500 to-blue-500"
>
Send Invite
</Button>
</div>
</div>
</div>
</Dialog>
</RadixDialog.Root>
)}
</>
);
};

View File

@@ -1,399 +0,0 @@
import React, { useState, useEffect } from 'react';
import { classNames } from '~/utils/classNames';
import { Dialog } from '~/components/ui/Dialog';
import * as RadixDialog from '@radix-ui/react-dialog';
import { Button } from '~/components/ui/Button';
import { Input } from '~/components/ui/Input';
import { toast } from 'react-toastify';
import { MultiUserSessionManager } from './MultiUserSessionManager';
interface MultiUserToggleProps {
className?: string;
}
export const MultiUserToggle: React.FC<MultiUserToggleProps> = ({ className }) => {
const [isEnabled, setIsEnabled] = useState(false);
const [showWizard, setShowWizard] = useState(false);
const [currentStep, setCurrentStep] = useState(1);
const [organizationName, setOrganizationName] = useState('');
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [maxUsers, setMaxUsers] = useState('10');
const [sessionTimeout, setSessionTimeout] = useState('30');
const [allowGuestAccess, setAllowGuestAccess] = useState(false);
// Check if this is bolt.gives (exclusive feature)
const isBoltGives = window.location.hostname === 'bolt.openweb.live' || window.location.hostname === 'localhost';
useEffect(() => {
// Check if multi-user is already enabled
const multiUserEnabled = localStorage.getItem('multiUserEnabled') === 'true';
setIsEnabled(multiUserEnabled);
}, []);
const handleToggle = () => {
if (!isBoltGives) {
toast.error('Multi-User Sessions is a Bolt.gives exclusive feature');
return;
}
if (!isEnabled) {
// Show wizard to set up multi-user
setShowWizard(true);
setCurrentStep(1);
} else {
// Confirm disable
if (window.confirm('Are you sure you want to disable Multi-User Sessions?')) {
setIsEnabled(false);
localStorage.setItem('multiUserEnabled', 'false');
toast.success('Multi-User Sessions disabled');
}
}
};
const handleNextStep = () => {
if (currentStep === 1) {
if (!organizationName.trim()) {
toast.error('Please enter an organization name');
return;
}
} else if (currentStep === 2) {
if (!adminEmail.trim() || !adminPassword.trim()) {
toast.error('Please enter admin credentials');
return;
}
if (adminPassword.length < 8) {
toast.error('Password must be at least 8 characters');
return;
}
}
if (currentStep < 4) {
setCurrentStep(currentStep + 1);
} else {
// Complete setup
handleCompleteSetup();
}
};
const handleCompleteSetup = async () => {
try {
// Save configuration
const config = {
organizationName,
adminEmail,
maxUsers: parseInt(maxUsers),
sessionTimeout: parseInt(sessionTimeout),
allowGuestAccess,
enabled: true,
setupDate: new Date().toISOString(),
};
// Store in localStorage (in production, this would be server-side)
localStorage.setItem('multiUserConfig', JSON.stringify(config));
localStorage.setItem('multiUserEnabled', 'true');
// Create admin user
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: adminEmail,
password: adminPassword,
role: 'admin',
organization: organizationName,
}),
});
if (response.ok) {
setIsEnabled(true);
setShowWizard(false);
toast.success('Multi-User Sessions enabled successfully!');
// Auto-login the admin
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: adminEmail,
password: adminPassword,
}),
});
if (loginResponse.ok) {
window.location.reload();
}
} else {
const error = (await response.json()) as { message?: string };
toast.error(error.message || 'Failed to setup Multi-User Sessions');
}
} catch (error) {
console.error('Setup error:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to setup Multi-User Sessions';
toast.error(errorMessage);
}
};
if (!isBoltGives) {
return null; // Feature not available for non-bolt.gives deployments
}
// If multi-user is enabled, show the session manager instead
if (isEnabled) {
return (
<div className="flex items-center gap-2">
<MultiUserSessionManager />
<button
onClick={handleToggle}
className="p-1.5 text-xs text-bolt-elements-textSecondary hover:text-red-400 transition-all"
title="Disable Multi-User Sessions"
>
<span className="i-ph:power text-sm" />
</button>
</div>
);
}
return (
<>
<button
onClick={handleToggle}
className={classNames(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all',
'border border-bolt-elements-borderColor',
'hover:bg-bolt-elements-background-depth-2',
isEnabled
? 'bg-gradient-to-r from-green-500/20 to-blue-500/20 border-green-500/30'
: 'bg-bolt-elements-background-depth-1',
className,
)}
title={isEnabled ? 'Multi-User Sessions Active' : 'Enable Multi-User Sessions'}
>
<span
className={classNames(
'text-sm',
isEnabled ? 'i-ph:users-three-fill text-green-400' : 'i-ph:users-three text-bolt-elements-textSecondary',
)}
/>
<span
className={classNames(
'text-xs font-medium hidden sm:inline',
isEnabled ? 'text-green-400' : 'text-bolt-elements-textSecondary',
)}
>
{isEnabled ? 'Multi-User' : 'Single User'}
</span>
</button>
{showWizard && (
<RadixDialog.Root open={showWizard} onOpenChange={setShowWizard}>
<Dialog className="max-w-md" onClose={() => setShowWizard(false)}>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary">Setup Multi-User Sessions</h2>
<span className="text-xs text-bolt-elements-textSecondary">Step {currentStep} of 4</span>
</div>
{/* Progress Bar */}
<div className="flex gap-1 mb-6">
{[1, 2, 3, 4].map((step) => (
<div
key={step}
className={classNames(
'flex-1 h-1 rounded-full transition-all',
step <= currentStep
? 'bg-gradient-to-r from-green-500 to-blue-500'
: 'bg-bolt-elements-borderColor',
)}
/>
))}
</div>
{/* Step 1: Organization Setup */}
{currentStep === 1 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Organization Setup</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Configure your organization for multi-user collaboration
</p>
</div>
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
Organization Name
</label>
<Input
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="e.g., Acme Corp"
className="w-full"
/>
</div>
</div>
)}
{/* Step 2: Admin Account */}
{currentStep === 2 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Admin Account</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Create the administrator account for managing users
</p>
</div>
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">Admin Email</label>
<Input
type="email"
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
placeholder="admin@example.com"
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
Admin Password
</label>
<Input
type="password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
placeholder="Minimum 8 characters"
className="w-full"
/>
</div>
</div>
)}
{/* Step 3: Session Settings */}
{currentStep === 3 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Session Settings</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Configure session limits and security
</p>
</div>
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
Maximum Concurrent Users
</label>
<Input
type="number"
value={maxUsers}
onChange={(e) => setMaxUsers(e.target.value)}
min="2"
max="100"
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
Session Timeout (minutes)
</label>
<Input
type="number"
value={sessionTimeout}
onChange={(e) => setSessionTimeout(e.target.value)}
min="5"
max="1440"
className="w-full"
/>
</div>
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={allowGuestAccess}
onChange={(e) => setAllowGuestAccess(e.target.checked)}
className="rounded border-bolt-elements-borderColor"
/>
<span className="text-sm text-bolt-elements-textPrimary">Allow guest access (read-only)</span>
</label>
</div>
</div>
)}
{/* Step 4: Review & Confirm */}
{currentStep === 4 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Review Configuration</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Please review your settings before enabling Multi-User Sessions
</p>
</div>
<div className="space-y-2 p-4 bg-bolt-elements-background-depth-2 rounded-lg">
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">Organization:</span>
<span className="text-bolt-elements-textPrimary font-medium">{organizationName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">Admin Email:</span>
<span className="text-bolt-elements-textPrimary font-medium">{adminEmail}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">Max Users:</span>
<span className="text-bolt-elements-textPrimary font-medium">{maxUsers}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">Session Timeout:</span>
<span className="text-bolt-elements-textPrimary font-medium">{sessionTimeout} minutes</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">Guest Access:</span>
<span className="text-bolt-elements-textPrimary font-medium">
{allowGuestAccess ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<p className="text-xs text-blue-400">
<span className="font-semibold">Note:</span> Multi-User Sessions is a Bolt.gives exclusive
feature. You can manage users, sessions, and permissions from the admin panel after setup.
</p>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-between mt-6">
<Button
variant="secondary"
onClick={() => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
} else {
setShowWizard(false);
}
}}
>
{currentStep === 1 ? 'Cancel' : 'Back'}
</Button>
<Button
variant="default"
onClick={handleNextStep}
className="bg-gradient-to-r from-green-500 to-blue-500"
>
{currentStep === 4 ? 'Enable Multi-User' : 'Next'}
</Button>
</div>
</div>
</Dialog>
</RadixDialog.Root>
)}
</>
);
};

View File

@@ -1,299 +0,0 @@
import { memo, useState, useEffect } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import * as Switch from '@radix-ui/react-switch';
import * as Slider from '@radix-ui/react-slider';
import { classNames } from '~/utils/classNames';
import { motion, AnimatePresence } from 'framer-motion';
interface AutoSaveSettingsProps {
onSettingsChange?: (settings: AutoSaveConfig) => void;
trigger?: React.ReactNode;
}
export interface AutoSaveConfig {
enabled: boolean;
interval: number; // in seconds
minChanges: number;
saveOnBlur: boolean;
saveBeforeRun: boolean;
showNotifications: boolean;
}
const DEFAULT_CONFIG: AutoSaveConfig = {
enabled: false,
interval: 30,
minChanges: 1,
saveOnBlur: true,
saveBeforeRun: true,
showNotifications: true,
};
const PRESET_INTERVALS = [
{ label: '10s', value: 10 },
{ label: '30s', value: 30 },
{ label: '1m', value: 60 },
{ label: '2m', value: 120 },
{ label: '5m', value: 300 },
];
export const AutoSaveSettings = memo(({ onSettingsChange, trigger }: AutoSaveSettingsProps) => {
const [isOpen, setIsOpen] = useState(false);
const [config, setConfig] = useState<AutoSaveConfig>(() => {
// Load from localStorage if available
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('bolt-autosave-config');
if (saved) {
try {
return JSON.parse(saved);
} catch {
// Invalid JSON, use defaults
}
}
}
return DEFAULT_CONFIG;
});
// Save to localStorage whenever config changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('bolt-autosave-config', JSON.stringify(config));
}
onSettingsChange?.(config);
}, [config, onSettingsChange]);
const updateConfig = <K extends keyof AutoSaveConfig>(key: K, value: AutoSaveConfig[K]) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
{trigger || (
<button className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary transition-colors">
<div className="i-ph:gear-duotone" />
<span className="text-sm">Auto-save Settings</span>
</button>
)}
</Dialog.Trigger>
<AnimatePresence>
{isOpen && (
<Dialog.Portal>
<Dialog.Overlay asChild>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
/>
</Dialog.Overlay>
<Dialog.Content asChild>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md"
>
<div className="bg-bolt-elements-background-depth-1 rounded-xl shadow-2xl border border-bolt-elements-borderColor">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-bolt-elements-borderColor">
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary">
Auto-save Settings
</Dialog.Title>
<Dialog.Close className="p-1 rounded-lg hover:bg-bolt-elements-background-depth-2 transition-colors">
<div className="i-ph:x text-xl text-bolt-elements-textTertiary" />
</Dialog.Close>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Enable/Disable Auto-save */}
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">Enable Auto-save</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Automatically save files at regular intervals
</p>
</div>
<Switch.Root
checked={config.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.enabled ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
{/* Save Interval */}
<div
className={classNames(
'space-y-3 transition-opacity',
!config.enabled ? 'opacity-50 pointer-events-none' : '',
)}
>
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Save Interval: {config.interval}s
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">How often to save changes</p>
</div>
<Slider.Root
value={[config.interval]}
onValueChange={([value]) => updateConfig('interval', value)}
min={5}
max={300}
step={5}
className="relative flex items-center select-none touch-none w-full h-5"
>
<Slider.Track className="bg-bolt-elements-background-depth-3 relative grow rounded-full h-1">
<Slider.Range className="absolute bg-accent-500 rounded-full h-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 bg-white rounded-full shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent-500" />
</Slider.Root>
{/* Preset buttons */}
<div className="flex gap-2">
{PRESET_INTERVALS.map((preset) => (
<button
key={preset.value}
onClick={() => updateConfig('interval', preset.value)}
className={classNames(
'px-2 py-1 text-xs rounded-md transition-colors',
config.interval === preset.value
? 'bg-accent-500 text-white'
: 'bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary hover:bg-bolt-elements-background-depth-3',
)}
>
{preset.label}
</button>
))}
</div>
</div>
{/* Minimum Changes */}
<div
className={classNames(
'space-y-3 transition-opacity',
!config.enabled ? 'opacity-50 pointer-events-none' : '',
)}
>
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Minimum Changes: {config.minChanges}
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Minimum number of files to trigger auto-save
</p>
</div>
<Slider.Root
value={[config.minChanges]}
onValueChange={([value]) => updateConfig('minChanges', value)}
min={1}
max={10}
step={1}
className="relative flex items-center select-none touch-none w-full h-5"
>
<Slider.Track className="bg-bolt-elements-background-depth-3 relative grow rounded-full h-1">
<Slider.Range className="absolute bg-accent-500 rounded-full h-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 bg-white rounded-full shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent-500" />
</Slider.Root>
</div>
{/* Additional Options */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Save on Tab Switch
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Save when switching to another tab
</p>
</div>
<Switch.Root
checked={config.saveOnBlur}
onCheckedChange={(checked) => updateConfig('saveOnBlur', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.saveOnBlur ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">Save Before Run</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Save all files before running commands
</p>
</div>
<Switch.Root
checked={config.saveBeforeRun}
onCheckedChange={(checked) => updateConfig('saveBeforeRun', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.saveBeforeRun ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Show Notifications
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Display toast notifications on save
</p>
</div>
<Switch.Root
checked={config.showNotifications}
onCheckedChange={(checked) => updateConfig('showNotifications', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.showNotifications ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-bolt-elements-borderColor">
<button
onClick={() => setConfig(DEFAULT_CONFIG)}
className="px-4 py-2 text-sm text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-colors"
>
Reset to Defaults
</button>
<Dialog.Close className="px-4 py-2 text-sm bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors">
Done
</Dialog.Close>
</div>
</div>
</motion.div>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
);
});
AutoSaveSettings.displayName = 'AutoSaveSettings';

View File

@@ -1,145 +0,0 @@
import { useStore } from '@nanostores/react';
import { memo, useMemo } from 'react';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { motion } from 'framer-motion';
interface FileStatusIndicatorProps {
className?: string;
showDetails?: boolean;
}
export const FileStatusIndicator = memo(({ className = '', showDetails = true }: FileStatusIndicatorProps) => {
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const stats = useMemo(() => {
let totalFiles = 0;
let totalFolders = 0;
let totalSize = 0;
Object.entries(files).forEach(([_path, dirent]) => {
if (dirent?.type === 'file') {
totalFiles++;
totalSize += dirent.content?.length || 0;
} else if (dirent?.type === 'folder') {
totalFolders++;
}
});
return {
totalFiles,
totalFolders,
unsavedCount: unsavedFiles.size,
totalSize: formatFileSize(totalSize),
modifiedPercentage: totalFiles > 0 ? Math.round((unsavedFiles.size / totalFiles) * 100) : 0,
};
}, [files, unsavedFiles]);
function formatFileSize(bytes: number): string {
if (bytes === 0) {
return '0 B';
}
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
const getStatusColor = () => {
if (stats.unsavedCount === 0) {
return 'text-green-500';
}
if (stats.modifiedPercentage > 50) {
return 'text-red-500';
}
if (stats.modifiedPercentage > 20) {
return 'text-yellow-500';
}
return 'text-orange-500';
};
return (
<div
className={classNames(
'flex items-center gap-4 px-3 py-1.5 rounded-lg',
'bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor',
'text-xs text-bolt-elements-textTertiary',
className,
)}
>
{/* Status dot with pulse animation */}
<div className="flex items-center gap-2">
<motion.div
animate={{
scale: stats.unsavedCount > 0 ? [1, 1.2, 1] : 1,
}}
transition={{
duration: 2,
repeat: stats.unsavedCount > 0 ? Infinity : 0,
repeatType: 'loop',
}}
className={classNames(
'w-2 h-2 rounded-full',
getStatusColor(),
stats.unsavedCount > 0 ? 'bg-current' : 'bg-green-500',
)}
/>
<span className={getStatusColor()}>
{stats.unsavedCount === 0 ? 'All saved' : `${stats.unsavedCount} unsaved`}
</span>
</div>
{showDetails && (
<>
{/* File count */}
<div className="flex items-center gap-1.5">
<div className="i-ph:file-duotone" />
<span>{stats.totalFiles} files</span>
</div>
{/* Folder count */}
<div className="flex items-center gap-1.5">
<div className="i-ph:folder-duotone" />
<span>{stats.totalFolders} folders</span>
</div>
{/* Total size */}
<div className="flex items-center gap-1.5">
<div className="i-ph:database-duotone" />
<span>{stats.totalSize}</span>
</div>
{/* Progress bar for unsaved files */}
{stats.unsavedCount > 0 && (
<div className="flex items-center gap-2 ml-auto">
<span className="text-xs">{stats.modifiedPercentage}% modified</span>
<div className="w-20 h-1.5 bg-bolt-elements-background-depth-2 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${stats.modifiedPercentage}%` }}
transition={{ duration: 0.3 }}
className={classNames(
'h-full rounded-full',
stats.modifiedPercentage > 50
? 'bg-red-500'
: stats.modifiedPercentage > 20
? 'bg-yellow-500'
: 'bg-orange-500',
)}
/>
</div>
</div>
)}
</>
)}
</div>
);
});
FileStatusIndicator.displayName = 'FileStatusIndicator';

View File

@@ -1,43 +0,0 @@
import { useEffect } from 'react';
import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
export function useKeyboardSaveAll() {
useEffect(() => {
const handleKeyPress = async (e: KeyboardEvent) => {
// Ctrl+Shift+S or Cmd+Shift+S to save all
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
e.preventDefault();
const unsavedFiles = workbenchStore.unsavedFiles.get();
if (unsavedFiles.size === 0) {
toast.info('All files are already saved', {
position: 'bottom-right',
autoClose: 2000,
});
return;
}
try {
const count = unsavedFiles.size;
await workbenchStore.saveAllFiles();
toast.success(`Saved ${count} file${count > 1 ? 's' : ''}`, {
position: 'bottom-right',
autoClose: 2000,
});
} catch {
toast.error('Failed to save some files', {
position: 'bottom-right',
autoClose: 3000,
});
}
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
}

View File

@@ -1,305 +0,0 @@
import { useStore } from '@nanostores/react';
import { memo, useCallback, useEffect, useState, useRef } from 'react';
import { toast } from 'react-toastify';
import * as Tooltip from '@radix-ui/react-tooltip';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
interface SaveAllButtonProps {
className?: string;
variant?: 'icon' | 'button';
showCount?: boolean;
autoSave?: boolean;
autoSaveInterval?: number;
}
export const SaveAllButton = memo(
({
className = '',
variant = 'icon',
showCount = true,
autoSave = false,
autoSaveInterval = 30000,
}: SaveAllButtonProps) => {
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const [isSaving, setIsSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [timeUntilAutoSave, setTimeUntilAutoSave] = useState<number | null>(null);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const countdownTimerRef = useRef<NodeJS.Timeout | null>(null);
const unsavedCount = unsavedFiles.size;
const hasUnsavedFiles = unsavedCount > 0;
// Log unsaved files state changes
useEffect(() => {
console.log('[SaveAllButton] Unsaved files changed:', {
count: unsavedCount,
files: Array.from(unsavedFiles),
hasUnsavedFiles,
});
}, [unsavedFiles, unsavedCount, hasUnsavedFiles]);
// Auto-save logic
useEffect(() => {
if (!autoSave || !hasUnsavedFiles) {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
setTimeUntilAutoSave(null);
return;
}
// Set up auto-save timer
console.log('[SaveAllButton] Setting up auto-save timer for', autoSaveInterval, 'ms');
autoSaveTimerRef.current = setTimeout(async () => {
if (hasUnsavedFiles && !isSaving) {
console.log('[SaveAllButton] Auto-save triggered');
await handleSaveAll(true);
}
}, autoSaveInterval);
// Set up countdown timer
const startTime = Date.now();
setTimeUntilAutoSave(Math.ceil(autoSaveInterval / 1000));
countdownTimerRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, autoSaveInterval - elapsed);
setTimeUntilAutoSave(Math.ceil(remaining / 1000));
if (remaining <= 0 && countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
}, 1000);
}, [autoSave, hasUnsavedFiles, autoSaveInterval, isSaving]);
// Cleanup effect
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
}
};
}, []);
const handleSaveAll = useCallback(
async (isAutoSave = false) => {
if (!hasUnsavedFiles || isSaving) {
console.log('[SaveAllButton] Skipping save:', { hasUnsavedFiles, isSaving });
return;
}
console.log('[SaveAllButton] Starting save:', {
unsavedCount,
isAutoSave,
files: Array.from(unsavedFiles),
});
setIsSaving(true);
const startTime = performance.now();
const savedFiles: string[] = [];
const failedFiles: string[] = [];
try {
// Save each file individually with detailed logging
for (const filePath of unsavedFiles) {
try {
console.log(`[SaveAllButton] Saving file: ${filePath}`);
await workbenchStore.saveFile(filePath);
savedFiles.push(filePath);
console.log(`[SaveAllButton] Successfully saved: ${filePath}`);
} catch (error) {
console.error(`[SaveAllButton] Failed to save ${filePath}:`, error);
failedFiles.push(filePath);
}
}
const endTime = performance.now();
const duration = Math.round(endTime - startTime);
setLastSaved(new Date());
// Check final state
const remainingUnsaved = workbenchStore.unsavedFiles.get();
console.log('[SaveAllButton] Save complete:', {
savedCount: savedFiles.length,
failedCount: failedFiles.length,
remainingUnsaved: Array.from(remainingUnsaved),
duration,
});
// Show appropriate feedback
if (failedFiles.length === 0) {
const message = isAutoSave
? `Auto-saved ${savedFiles.length} file${savedFiles.length > 1 ? 's' : ''}`
: `Saved ${savedFiles.length} file${savedFiles.length > 1 ? 's' : ''}`;
toast.success(message, {
position: 'bottom-right',
autoClose: 2000,
});
} else {
toast.warning(`Saved ${savedFiles.length} files, ${failedFiles.length} failed`, {
position: 'bottom-right',
autoClose: 3000,
});
}
} catch (error) {
console.error('[SaveAllButton] Critical error during save:', error);
toast.error('Failed to save files', {
position: 'bottom-right',
autoClose: 3000,
});
} finally {
setIsSaving(false);
}
},
[hasUnsavedFiles, isSaving, unsavedCount, unsavedFiles],
);
// Keyboard shortcut
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
e.preventDefault();
handleSaveAll(false);
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [handleSaveAll]);
const formatLastSaved = () => {
if (!lastSaved) {
return null;
}
const now = new Date();
const diff = Math.floor((now.getTime() - lastSaved.getTime()) / 1000);
if (diff < 60) {
return `${diff}s ago`;
}
if (diff < 3600) {
return `${Math.floor(diff / 60)}m ago`;
}
return `${Math.floor(diff / 3600)}h ago`;
};
// Icon-only variant for header
if (variant === 'icon') {
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => handleSaveAll(false)}
disabled={!hasUnsavedFiles || isSaving}
className={classNames(
'relative p-1.5 rounded-md transition-colors',
hasUnsavedFiles
? 'text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive'
: 'text-bolt-elements-textTertiary cursor-not-allowed opacity-50',
className,
)}
>
<div className="relative">
<div className={isSaving ? 'animate-spin' : ''}>
<div className="i-ph:floppy-disk text-lg" />
</div>
{hasUnsavedFiles && showCount && !isSaving && (
<div className="absolute -top-1 -right-1 min-w-[12px] h-[12px] bg-red-500 text-white rounded-full flex items-center justify-center text-[8px] font-bold">
{unsavedCount}
</div>
)}
</div>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary px-3 py-2 rounded-lg shadow-lg border border-bolt-elements-borderColor z-[9999]"
sideOffset={5}
>
<div className="text-xs space-y-1">
<div className="font-semibold">
{hasUnsavedFiles ? `${unsavedCount} unsaved file${unsavedCount > 1 ? 's' : ''}` : 'All files saved'}
</div>
{lastSaved && <div className="text-bolt-elements-textTertiary">Last saved: {formatLastSaved()}</div>}
{autoSave && hasUnsavedFiles && timeUntilAutoSave && (
<div className="text-bolt-elements-textTertiary">Auto-save in: {timeUntilAutoSave}s</div>
)}
<div className="border-t border-bolt-elements-borderColor pt-1 mt-1">
<kbd className="text-xs">Ctrl+Shift+S</kbd> to save all
</div>
</div>
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
// Button variant
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => handleSaveAll(false)}
disabled={!hasUnsavedFiles || isSaving}
className={classNames(
'inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
hasUnsavedFiles
? 'bg-accent-500 hover:bg-accent-600 text-white'
: 'bg-bolt-elements-background-depth-1 text-bolt-elements-textTertiary border border-bolt-elements-borderColor cursor-not-allowed opacity-60',
className,
)}
>
<div className={isSaving ? 'animate-spin' : ''}>
<div className="i-ph:floppy-disk" />
</div>
<span>
{isSaving ? 'Saving...' : `Save All${showCount && hasUnsavedFiles ? ` (${unsavedCount})` : ''}`}
</span>
{autoSave && timeUntilAutoSave && hasUnsavedFiles && (
<span className="text-xs opacity-75">({timeUntilAutoSave}s)</span>
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary px-3 py-2 rounded-lg shadow-lg border border-bolt-elements-borderColor z-[9999]"
sideOffset={5}
>
<div className="text-xs">
<kbd>Ctrl+Shift+S</kbd> to save all
</div>
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
},
);
SaveAllButton.displayName = 'SaveAllButton';

View File

@@ -13,7 +13,6 @@ import {
type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor';
import { IconButton } from '~/components/ui/IconButton';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import { Slider, type SliderOptions } from '~/components/ui/Slider';
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
@@ -23,11 +22,13 @@ import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import useViewport from '~/lib/hooks';
// import { GitLabDeploymentDialog } from '~/components/deploy/GitLabDeploymentDialog';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { usePreviewStore } from '~/lib/stores/previews';
import { chatStore } from '~/lib/stores/chat';
import type { ElementInfo } from './Inspector';
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
import { useChatHistory } from '~/lib/persistence';
import { streamingState } from '~/lib/stores/streaming';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
interface WorkspaceProps {
chatStarted?: boolean;
@@ -279,298 +280,239 @@ const FileModifiedDropdown = memo(
},
);
export const Workbench = memo(({ chatStarted, isStreaming, setSelectedElement }: WorkspaceProps) => {
renderLogger.trace('Workbench');
export const Workbench = memo(
({
chatStarted,
isStreaming,
metadata: _metadata,
updateChatMestaData: _updateChatMestaData,
setSelectedElement,
}: WorkspaceProps) => {
renderLogger.trace('Workbench');
const [isSyncing, setIsSyncing] = useState(false);
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
// const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
// const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
// Keyboard shortcut for Save All (Ctrl+Shift+S)
useEffect(() => {
const handleKeyPress = async (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
e.preventDefault();
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
const unsavedFiles = workbenchStore.unsavedFiles.get();
const isSmallViewport = useViewport(1024);
const streaming = useStore(streamingState);
const { exportChat } = useChatHistory();
const [isSyncing, setIsSyncing] = useState(false);
if (unsavedFiles.size > 0) {
try {
await workbenchStore.saveAllFiles();
toast.success(`Saved ${unsavedFiles.size} file${unsavedFiles.size > 1 ? 's' : ''}`, {
position: 'bottom-right',
autoClose: 2000,
});
} catch {
toast.error('Failed to save some files', {
position: 'bottom-right',
autoClose: 3000,
});
}
} else {
toast.info('All files are already saved', {
position: 'bottom-right',
autoClose: 2000,
});
}
}
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');
}
}, [hasPreview]);
// const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const isSmallViewport = useViewport(1024);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');
}
}, [hasPreview]);
const onFileSave = useCallback(() => {
workbenchStore
.saveCurrentDocument()
.then(() => {
// Explicitly refresh all previews after a file save
const previewStore = usePreviewStore();
previewStore.refreshAllPreviews();
})
.catch(() => {
toast.error('Failed to update file content');
});
}, []);
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const handleSelectFile = useCallback((filePath: string) => {
workbenchStore.setSelectedFile(filePath);
workbenchStore.currentView.set('diff');
}, []);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const handleSyncFiles = useCallback(async () => {
setIsSyncing(true);
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
try {
const directoryHandle = await window.showDirectoryPicker();
await workbenchStore.syncFiles(directoryHandle);
toast.success('Files synced successfully');
} catch (error) {
console.error('Error syncing files:', error);
toast.error('Failed to sync files');
} finally {
setIsSyncing(false);
}
}, []);
const onFileSave = useCallback(() => {
workbenchStore
.saveCurrentDocument()
.then(() => {
// Explicitly refresh all previews after a file save
const previewStore = usePreviewStore();
previewStore.refreshAllPreviews();
})
.catch(() => {
toast.error('Failed to update file content');
});
}, []);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
const handleSyncFiles = useCallback(async () => {
setIsSyncing(true);
try {
const directoryHandle = await window.showDirectoryPicker();
await workbenchStore.syncFiles(directoryHandle);
toast.success('Files synced successfully');
} catch {
console.error('Error syncing files');
toast.error('Failed to sync files');
} finally {
setIsSyncing(false);
}
}, []);
const handleSelectFile = useCallback((filePath: string) => {
workbenchStore.setSelectedFile(filePath);
workbenchStore.currentView.set('diff');
}, []);
return (
chatStarted && (
<motion.div
initial="closed"
animate={showWorkbench ? 'open' : 'closed'}
variants={workbenchVariants}
className="z-workbench"
>
<div
className={classNames(
'fixed top-[calc(var(--header-height)+1.2rem)] bottom-6 w-[var(--workbench-inner-width)] z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
{
'w-full': isSmallViewport,
'left-0': showWorkbench && isSmallViewport,
'left-[var(--workbench-left)]': showWorkbench,
'left-[100%]': !showWorkbench,
},
)}
return (
chatStarted && (
<motion.div
initial="closed"
animate={showWorkbench ? 'open' : 'closed'}
variants={workbenchVariants}
className="z-workbench"
>
<div className="absolute inset-0 px-2 lg:px-4">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor gap-1.5">
<button
className={`${showChat ? 'i-ph:sidebar-simple-fill' : 'i-ph:sidebar-simple'} text-lg text-bolt-elements-textSecondary mr-1`}
disabled={!canHideChat || isSmallViewport}
onClick={() => {
if (canHideChat) {
chatStore.setKey('showChat', !showChat);
}
}}
/>
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<div className="flex overflow-y-auto">
<PanelHeaderButton
className="mr-1 text-sm"
onClick={async () => {
console.log('[SaveAll] Button clicked');
const unsavedFiles = workbenchStore.unsavedFiles.get();
console.log('[SaveAll] Unsaved files:', Array.from(unsavedFiles));
if (unsavedFiles.size > 0) {
try {
console.log('[SaveAll] Starting save...');
await workbenchStore.saveAllFiles();
toast.success(`Saved ${unsavedFiles.size} file${unsavedFiles.size > 1 ? 's' : ''}`, {
position: 'bottom-right',
autoClose: 2000,
});
console.log('[SaveAll] Save successful');
} catch {
console.error('[SaveAll] Save failed');
toast.error('Failed to save files', {
position: 'bottom-right',
autoClose: 3000,
});
}
} else {
console.log('[SaveAll] No unsaved files');
toast.info('All files are already saved', {
position: 'bottom-right',
autoClose: 2000,
});
}
}}
>
<div className="i-ph:floppy-disk" />
Save All
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed">
<div className="i-ph:box-arrow-up" />
Sync
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'min-w-[240px] z-[250]',
'bg-white dark:bg-[#141414]',
'rounded-lg shadow-lg',
'border border-gray-200/50 dark:border-gray-800/50',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={handleSyncFiles}
disabled={isSyncing}
>
<div className="flex items-center gap-2">
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
<span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span>
</div>
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={() => {
/* GitHub push temporarily disabled */
}}
>
<div className="flex items-center gap-2">
<div className="i-ph:git-branch" />
Push to GitHub
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
)}
{selectedView === 'diff' && (
<FileModifiedDropdown fileHistory={fileHistory} onSelectFile={handleSelectFile} />
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
size="xl"
onClick={() => {
workbenchStore.showWorkbench.set(false);
}}
/>
</div>
<div className="relative flex-1 overflow-hidden">
<View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
<div
className={classNames(
'fixed top-[calc(var(--header-height)+1.2rem)] bottom-6 w-[var(--workbench-inner-width)] z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
{
'w-full': isSmallViewport,
'left-0': showWorkbench && isSmallViewport,
'left-[var(--workbench-left)]': showWorkbench,
'left-[100%]': !showWorkbench,
},
)}
>
<div className="absolute inset-0 px-2 lg:px-4">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor gap-1.5">
<button
className={`${showChat ? 'i-ph:sidebar-simple-fill' : 'i-ph:sidebar-simple'} text-lg text-bolt-elements-textSecondary mr-1`}
disabled={!canHideChat || isSmallViewport}
onClick={() => {
if (canHideChat) {
chatStore.setKey('showChat', !showChat);
}
}}
/>
</View>
<View
initial={{ x: '100%' }}
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
>
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} />
</View>
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
<Preview setSelectedElement={setSelectedElement} />
</View>
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<div className="flex overflow-y-auto">
{/* Export Chat Button */}
<ExportChatButton exportChat={exportChat} />
{/* Sync Button */}
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden ml-1">
<DropdownMenu.Root>
<DropdownMenu.Trigger
disabled={isSyncing || streaming}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
{isSyncing ? 'Syncing...' : 'Sync'}
<span className={classNames('i-ph:caret-down transition-transform')} />
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'min-w-[240px] z-[250]',
'bg-white dark:bg-[#141414]',
'rounded-lg shadow-lg',
'border border-gray-200/50 dark:border-gray-800/50',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={handleSyncFiles}
disabled={isSyncing}
>
<div className="flex items-center gap-2">
{isSyncing ? (
<div className="i-ph:spinner" />
) : (
<div className="i-ph:cloud-arrow-down" />
)}
<span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span>
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/* Toggle Terminal Button */}
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden ml-1">
<button
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
<div className="i-ph:terminal" />
Toggle Terminal
</button>
</div>
</div>
)}
{selectedView === 'diff' && (
<FileModifiedDropdown fileHistory={fileHistory} onSelectFile={handleSelectFile} />
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
size="xl"
onClick={() => {
workbenchStore.showWorkbench.set(false);
}}
/>
</div>
<div className="relative flex-1 overflow-hidden">
<View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
/>
</View>
<View
initial={{ x: '100%' }}
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
>
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} />
</View>
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
<Preview setSelectedElement={setSelectedElement} />
</View>
</div>
</div>
</div>
</div>
</div>
{/* GitHub push dialog temporarily disabled during merge - will be re-enabled with new GitLab integration */}
</motion.div>
)
);
});
</motion.div>
)
);
},
);
// View component for rendering content with motion transitions
interface ViewProps extends HTMLMotionProps<'div'> {

View File

@@ -1,268 +0,0 @@
/**
* Stream Recovery Module
* Handles stream failures and provides automatic recovery mechanisms
* Fixes chat conversation hanging issues
* Author: Keoma Wright
*/
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('stream-recovery');
export interface StreamRecoveryOptions {
maxRetries?: number;
retryDelay?: number;
timeout?: number;
onRetry?: (attempt: number) => void;
onTimeout?: () => void;
onError?: (error: any) => void;
}
export class StreamRecoveryManager {
private _retryCount = 0;
private _timeoutHandle: NodeJS.Timeout | null = null;
private _lastActivity: number = Date.now();
private _isActive = true;
constructor(private _options: StreamRecoveryOptions = {}) {
this._options = {
maxRetries: 3,
retryDelay: 1000,
timeout: 30000, // 30 seconds default timeout
..._options,
};
}
/**
* Start monitoring the stream for inactivity
*/
startMonitoring() {
this._resetTimeout();
}
/**
* Reset the timeout when activity is detected
*/
recordActivity() {
this._lastActivity = Date.now();
this._resetTimeout();
}
/**
* Reset the timeout timer
*/
private _resetTimeout() {
if (this._timeoutHandle) {
clearTimeout(this._timeoutHandle);
}
if (!this._isActive) {
return;
}
this._timeoutHandle = setTimeout(() => {
const inactiveTime = Date.now() - this._lastActivity;
logger.warn(`Stream timeout detected after ${inactiveTime}ms of inactivity`);
if (this._options.onTimeout) {
this._options.onTimeout();
}
this._handleTimeout();
}, this._options.timeout!);
}
/**
* Handle stream timeout
*/
private _handleTimeout() {
logger.error('Stream timeout - attempting recovery');
// Signal that recovery is needed
this.attemptRecovery();
}
/**
* Attempt to recover from a stream failure
*/
async attemptRecovery(): Promise<boolean> {
if (this._retryCount >= this._options.maxRetries!) {
logger.error(`Max retries (${this._options.maxRetries}) reached - cannot recover`);
return false;
}
this._retryCount++;
logger.info(`Attempting recovery (attempt ${this._retryCount}/${this._options.maxRetries})`);
if (this._options.onRetry) {
this._options.onRetry(this._retryCount);
}
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, this._options.retryDelay! * this._retryCount));
// Reset activity tracking
this.recordActivity();
return true;
}
/**
* Handle stream errors with recovery
*/
async handleError(error: any): Promise<boolean> {
logger.error('Stream error detected:', error);
if (this._options.onError) {
this._options.onError(error);
}
// Check if error is recoverable
if (this._isRecoverableError(error)) {
return await this.attemptRecovery();
}
logger.error('Non-recoverable error - cannot continue');
return false;
}
/**
* Check if an error is recoverable
*/
private _isRecoverableError(error: any): boolean {
const errorMessage = error?.message || error?.toString() || '';
// List of recoverable error patterns
const recoverablePatterns = [
'ECONNRESET',
'ETIMEDOUT',
'ENOTFOUND',
'socket hang up',
'network',
'timeout',
'abort',
'EPIPE',
'502',
'503',
'504',
'rate limit',
];
return recoverablePatterns.some((pattern) => errorMessage.toLowerCase().includes(pattern.toLowerCase()));
}
/**
* Stop monitoring and cleanup
*/
stop() {
this._isActive = false;
if (this._timeoutHandle) {
clearTimeout(this._timeoutHandle);
this._timeoutHandle = null;
}
}
/**
* Reset the recovery manager
*/
reset() {
this._retryCount = 0;
this._lastActivity = Date.now();
this._isActive = true;
this._resetTimeout();
}
}
/**
* Create a wrapped stream with recovery capabilities
*/
export function createRecoverableStream<T>(
streamFactory: () => Promise<ReadableStream<T>>,
options?: StreamRecoveryOptions,
): ReadableStream<T> {
const recovery = new StreamRecoveryManager(options);
let currentStream: ReadableStream<T> | null = null;
let reader: ReadableStreamDefaultReader<T> | null = null;
return new ReadableStream<T>({
async start(controller) {
recovery.startMonitoring();
try {
currentStream = await streamFactory();
reader = currentStream.getReader();
} catch (error) {
logger.error('Failed to create initial stream:', error);
const canRecover = await recovery.handleError(error);
if (canRecover) {
// Retry creating the stream
currentStream = await streamFactory();
reader = currentStream.getReader();
} else {
controller.error(error);
return;
}
}
},
async pull(controller) {
if (!reader) {
controller.error(new Error('No reader available'));
return;
}
try {
const { done, value } = await reader.read();
if (done) {
controller.close();
recovery.stop();
return;
}
// Record activity to reset timeout
recovery.recordActivity();
controller.enqueue(value);
} catch (error) {
logger.error('Error reading from stream:', error);
const canRecover = await recovery.handleError(error);
if (canRecover) {
// Try to recreate the stream
try {
if (reader) {
reader.releaseLock();
}
currentStream = await streamFactory();
reader = currentStream.getReader();
// Continue reading
await this.pull!(controller);
} catch (retryError) {
logger.error('Recovery failed:', retryError);
controller.error(retryError);
recovery.stop();
}
} else {
controller.error(error);
recovery.stop();
}
}
},
cancel() {
recovery.stop();
if (reader) {
reader.releaseLock();
}
},
});
}

View File

@@ -11,65 +11,6 @@ import { createFilesContext, extractPropertiesFromMessage } from './utils';
import { discussPrompt } from '~/lib/common/prompts/discuss-prompt';
import type { DesignScheme } from '~/types/design-scheme';
function getSmartAISystemPrompt(basePrompt: string): string {
const smartAIEnhancement = `
## SmartAI Mode - Enhanced Conversational Coding Assistant
You are operating in SmartAI mode, a premium Bolt.gives feature that provides detailed, educational feedback throughout the coding process.
### Your Communication Style:
- Be conversational and friendly, as if pair programming with a colleague
- Explain your thought process clearly and educationally
- Use natural language, not technical jargon unless necessary
- Keep responses visible and engaging
### What to Communicate:
**When Starting Tasks:**
✨ "I see you want [task description]. Let me [approach explanation]..."
✨ Explain your understanding and planned approach
✨ Share why you're choosing specific solutions
**During Implementation:**
📝 "Now I'm creating/updating [file] to [purpose]..."
📝 Explain what each code section does
📝 Share the patterns and best practices you're using
📝 Discuss any trade-offs or alternatives considered
**When Problem-Solving:**
🔍 "I noticed [issue]. This is likely because [reasoning]..."
🔍 Share your debugging thought process
🔍 Explain how you're identifying and fixing issues
🔍 Describe why your solution will work
**After Completing Work:**
✅ "I've successfully [what was done]. The key changes include..."
✅ Summarize what was accomplished
✅ Highlight important decisions made
✅ Suggest potential improvements or next steps
### Example Responses:
Instead of silence:
"I understand you need a contact form. Let me create a modern, accessible form with proper validation. I'll start by setting up the form structure with semantic HTML..."
While coding:
"I'm now adding email validation to ensure users enter valid email addresses. I'll use a regex pattern that covers most common email formats while keeping it user-friendly..."
When debugging:
"I see the button isn't aligning properly with the other elements. This looks like a flexbox issue. Let me adjust the container's display properties to fix the alignment..."
### Remember:
- Users chose SmartAI to learn from your process
- Make every action visible and understandable
- Be their coding companion, not just a silent worker
- Keep the conversation flowing naturally
${basePrompt}`;
return smartAIEnhancement;
}
export type Messages = Message[];
export interface StreamingOptions extends Omit<Parameters<typeof _streamText>[0], 'model'> {
@@ -141,19 +82,13 @@ export async function streamText(props: {
} = props;
let currentModel = DEFAULT_MODEL;
let currentProvider = DEFAULT_PROVIDER.name;
let smartAIEnabled = false;
let processedMessages = messages.map((message) => {
const newMessage = { ...message };
if (message.role === 'user') {
const { model, provider, content, smartAI } = extractPropertiesFromMessage(message);
const { model, provider, content } = extractPropertiesFromMessage(message);
currentModel = model;
currentProvider = provider;
if (smartAI !== undefined) {
smartAIEnabled = smartAI;
}
newMessage.content = sanitizeText(content);
} else if (message.role == 'assistant') {
newMessage.content = sanitizeText(message.content);
@@ -207,39 +142,13 @@ export async function streamText(props: {
const dynamicMaxTokens = modelDetails ? getCompletionTokenLimit(modelDetails) : Math.min(MAX_TOKENS, 16384);
// Additional safety cap - respect model-specific limits
let safeMaxTokens = dynamicMaxTokens;
// Apply model-specific caps for Anthropic models
if (modelDetails?.provider === 'Anthropic') {
if (modelDetails.name.includes('claude-sonnet-4') || modelDetails.name.includes('claude-opus-4')) {
safeMaxTokens = Math.min(dynamicMaxTokens, 64000);
} else if (modelDetails.name.includes('claude-3-7-sonnet')) {
safeMaxTokens = Math.min(dynamicMaxTokens, 64000);
} else if (modelDetails.name.includes('claude-3-5-sonnet')) {
safeMaxTokens = Math.min(dynamicMaxTokens, 8192);
} else {
safeMaxTokens = Math.min(dynamicMaxTokens, 4096);
}
} else {
// General safety cap for other providers
safeMaxTokens = Math.min(dynamicMaxTokens, 128000);
}
// Use model-specific limits directly - no artificial cap needed
const safeMaxTokens = dynamicMaxTokens;
logger.info(
`Max tokens for model ${modelDetails.name} is ${safeMaxTokens} (capped from ${dynamicMaxTokens}) based on model limits`,
`Token limits for model ${modelDetails.name}: maxTokens=${safeMaxTokens}, maxTokenAllowed=${modelDetails.maxTokenAllowed}, maxCompletionTokens=${modelDetails.maxCompletionTokens}`,
);
/*
* Check if SmartAI is enabled for supported models
* SmartAI is enabled if either:
* 1. The model itself has isSmartAIEnabled flag (for models with SmartAI in name)
* 2. The user explicitly enabled it via message flag
*/
const isSmartAISupported =
modelDetails?.supportsSmartAI && (provider.name === 'Anthropic' || provider.name === 'OpenAI');
const useSmartAI = (modelDetails?.isSmartAIEnabled || smartAIEnabled) && isSmartAISupported;
let systemPrompt =
PromptLibrary.getPropmtFromLibrary(promptId || 'default', {
cwd: WORK_DIR,
@@ -253,11 +162,6 @@ export async function streamText(props: {
},
}) ?? getSystemPrompt();
// Enhance system prompt for SmartAI if enabled and supported
if (useSmartAI) {
systemPrompt = getSmartAISystemPrompt(systemPrompt);
}
if (chatMode === 'build' && contextFiles && contextOptimization) {
const codeContext = createFilesContext(contextFiles, true);
@@ -317,11 +221,18 @@ export async function streamText(props: {
logger.info(`Sending llm call to ${provider.name} with model ${modelDetails.name}`);
// DEBUG: Log reasoning model detection
// Log reasoning model detection and token parameters
const isReasoning = isReasoningModel(modelDetails.name);
logger.info(`DEBUG STREAM: Model "${modelDetails.name}" detected as reasoning model: ${isReasoning}`);
logger.info(
`Model "${modelDetails.name}" is reasoning model: ${isReasoning}, using ${isReasoning ? 'maxCompletionTokens' : 'maxTokens'}: ${safeMaxTokens}`,
);
// console.log(systemPrompt, processedMessages);
// Validate token limits before API call
if (safeMaxTokens > (modelDetails.maxTokenAllowed || 128000)) {
logger.warn(
`Token limit warning: requesting ${safeMaxTokens} tokens but model supports max ${modelDetails.maxTokenAllowed || 128000}`,
);
}
// Use maxCompletionTokens for reasoning models (o1, GPT-5), maxTokens for traditional models
const tokenParams = isReasoning ? { maxCompletionTokens: safeMaxTokens } : { maxTokens: safeMaxTokens };

View File

@@ -8,7 +8,6 @@ export function extractPropertiesFromMessage(message: Omit<Message, 'id'>): {
model: string;
provider: string;
content: string;
smartAI?: boolean;
} {
const textContent = Array.isArray(message.content)
? message.content.find((item) => item.type === 'text')?.text || ''
@@ -17,10 +16,6 @@ export function extractPropertiesFromMessage(message: Omit<Message, 'id'>): {
const modelMatch = textContent.match(MODEL_REGEX);
const providerMatch = textContent.match(PROVIDER_REGEX);
// Check for SmartAI toggle in the message
const smartAIMatch = textContent.match(/\[SmartAI:(true|false)\]/);
const smartAI = smartAIMatch ? smartAIMatch[1] === 'true' : undefined;
/*
* Extract model
* const modelMatch = message.content.match(MODEL_REGEX);
@@ -38,21 +33,15 @@ export function extractPropertiesFromMessage(message: Omit<Message, 'id'>): {
if (item.type === 'text') {
return {
type: 'text',
text: item.text
?.replace(MODEL_REGEX, '')
.replace(PROVIDER_REGEX, '')
.replace(/\[SmartAI:(true|false)\]/g, ''),
text: item.text?.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, ''),
};
}
return item; // Preserve image_url and other types as is
})
: textContent
.replace(MODEL_REGEX, '')
.replace(PROVIDER_REGEX, '')
.replace(/\[SmartAI:(true|false)\]/g, '');
: textContent.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
return { model, provider, content: cleanedContent, smartAI };
return { model, provider, content: cleanedContent };
}
export function simplifyBoltActions(input: string): string {

View File

@@ -1,374 +0,0 @@
/**
* Netlify Configuration Helper
* Contributed by Keoma Wright
*
* This module provides automatic configuration generation for Netlify deployments
*/
export interface NetlifyConfig {
build: {
command?: string;
publish: string;
functions?: string;
environment?: Record<string, string>;
};
redirects?: Array<{
from: string;
to: string;
status?: number;
force?: boolean;
}>;
headers?: Array<{
for: string;
values: Record<string, string>;
}>;
functions?: {
[key: string]: {
included_files?: string[];
external_node_modules?: string[];
};
};
}
export interface FrameworkConfig {
name: string;
buildCommand: string;
outputDirectory: string;
nodeVersion: string;
installCommand?: string;
envVars?: Record<string, string>;
}
const FRAMEWORK_CONFIGS: Record<string, FrameworkConfig> = {
react: {
name: 'React',
buildCommand: 'npm run build',
outputDirectory: 'build',
nodeVersion: '18',
installCommand: 'npm install',
},
'react-vite': {
name: 'React (Vite)',
buildCommand: 'npm run build',
outputDirectory: 'dist',
nodeVersion: '18',
installCommand: 'npm install',
},
vue: {
name: 'Vue',
buildCommand: 'npm run build',
outputDirectory: 'dist',
nodeVersion: '18',
installCommand: 'npm install',
},
angular: {
name: 'Angular',
buildCommand: 'npm run build',
outputDirectory: 'dist',
nodeVersion: '18',
installCommand: 'npm install',
},
svelte: {
name: 'Svelte',
buildCommand: 'npm run build',
outputDirectory: 'public',
nodeVersion: '18',
installCommand: 'npm install',
},
'svelte-kit': {
name: 'SvelteKit',
buildCommand: 'npm run build',
outputDirectory: '.svelte-kit',
nodeVersion: '18',
installCommand: 'npm install',
},
next: {
name: 'Next.js',
buildCommand: 'npm run build',
outputDirectory: '.next',
nodeVersion: '18',
installCommand: 'npm install',
envVars: {
NEXT_TELEMETRY_DISABLED: '1',
},
},
nuxt: {
name: 'Nuxt',
buildCommand: 'npm run build',
outputDirectory: '.output/public',
nodeVersion: '18',
installCommand: 'npm install',
},
gatsby: {
name: 'Gatsby',
buildCommand: 'npm run build',
outputDirectory: 'public',
nodeVersion: '18',
installCommand: 'npm install',
},
remix: {
name: 'Remix',
buildCommand: 'npm run build',
outputDirectory: 'public',
nodeVersion: '18',
installCommand: 'npm install',
},
astro: {
name: 'Astro',
buildCommand: 'npm run build',
outputDirectory: 'dist',
nodeVersion: '18',
installCommand: 'npm install',
},
static: {
name: 'Static Site',
buildCommand: '',
outputDirectory: '.',
nodeVersion: '18',
},
};
export function detectFramework(packageJson: any): string {
const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
// Check for specific frameworks
if (deps.next) {
return 'next';
}
if (deps.nuxt || deps.nuxt3) {
return 'nuxt';
}
if (deps.gatsby) {
return 'gatsby';
}
if (deps['@remix-run/react']) {
return 'remix';
}
if (deps.astro) {
return 'astro';
}
if (deps['@angular/core']) {
return 'angular';
}
if (deps['@sveltejs/kit']) {
return 'svelte-kit';
}
if (deps.svelte) {
return 'svelte';
}
if (deps.vue) {
return 'vue';
}
if (deps.react) {
if (deps.vite) {
return 'react-vite';
}
return 'react';
}
return 'static';
}
export function generateNetlifyConfig(framework: string, customConfig?: Partial<NetlifyConfig>): NetlifyConfig {
const frameworkConfig = FRAMEWORK_CONFIGS[framework] || FRAMEWORK_CONFIGS.static;
const config: NetlifyConfig = {
build: {
command: frameworkConfig.buildCommand,
publish: frameworkConfig.outputDirectory,
environment: {
NODE_VERSION: frameworkConfig.nodeVersion,
...frameworkConfig.envVars,
...customConfig?.build?.environment,
},
},
redirects: [],
headers: [
{
for: '/*',
values: {
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
},
},
],
};
// Add SPA redirect for client-side routing frameworks
if (['react', 'react-vite', 'vue', 'angular', 'svelte'].includes(framework)) {
config.redirects!.push({
from: '/*',
to: '/index.html',
status: 200,
});
}
// Add custom headers for static assets
config.headers!.push({
for: '/assets/*',
values: {
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
// Merge with custom config
if (customConfig) {
if (customConfig.redirects) {
config.redirects!.push(...customConfig.redirects);
}
if (customConfig.headers) {
config.headers!.push(...customConfig.headers);
}
if (customConfig.functions) {
config.functions = customConfig.functions;
}
}
return config;
}
export function generateNetlifyToml(config: NetlifyConfig): string {
let toml = '';
// Build configuration
toml += '[build]\n';
if (config.build.command) {
toml += ` command = "${config.build.command}"\n`;
}
toml += ` publish = "${config.build.publish}"\n`;
if (config.build.functions) {
toml += ` functions = "${config.build.functions}"\n`;
}
// Environment variables
if (config.build.environment && Object.keys(config.build.environment).length > 0) {
toml += '\n[build.environment]\n';
for (const [key, value] of Object.entries(config.build.environment)) {
toml += ` ${key} = "${value}"\n`;
}
}
// Redirects
if (config.redirects && config.redirects.length > 0) {
for (const redirect of config.redirects) {
toml += '\n[[redirects]]\n';
toml += ` from = "${redirect.from}"\n`;
toml += ` to = "${redirect.to}"\n`;
if (redirect.status) {
toml += ` status = ${redirect.status}\n`;
}
if (redirect.force) {
toml += ` force = ${redirect.force}\n`;
}
}
}
// Headers
if (config.headers && config.headers.length > 0) {
for (const header of config.headers) {
toml += '\n[[headers]]\n';
toml += ` for = "${header.for}"\n`;
if (Object.keys(header.values).length > 0) {
toml += ' [headers.values]\n';
for (const [key, value] of Object.entries(header.values)) {
toml += ` "${key}" = "${value}"\n`;
}
}
}
}
// Functions configuration
if (config.functions) {
for (const [funcName, funcConfig] of Object.entries(config.functions)) {
toml += `\n[functions."${funcName}"]\n`;
if (funcConfig.included_files) {
toml += ` included_files = ${JSON.stringify(funcConfig.included_files)}\n`;
}
if (funcConfig.external_node_modules) {
toml += ` external_node_modules = ${JSON.stringify(funcConfig.external_node_modules)}\n`;
}
}
}
return toml;
}
export function validateDeploymentFiles(files: Record<string, string>): {
valid: boolean;
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
// Check for index.html
const hasIndex = Object.keys(files).some(
(path) => path === '/index.html' || path === 'index.html' || path.endsWith('/index.html'),
);
if (!hasIndex) {
warnings.push('No index.html file found. Make sure your build output includes an entry point.');
}
// Check file sizes
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
const WARN_FILE_SIZE = 10 * 1024 * 1024; // 10MB
for (const [path, content] of Object.entries(files)) {
const size = new Blob([content]).size;
if (size > MAX_FILE_SIZE) {
errors.push(`File ${path} exceeds maximum size of 100MB`);
} else if (size > WARN_FILE_SIZE) {
warnings.push(`File ${path} is large (${Math.round(size / 1024 / 1024)}MB)`);
}
}
// Check total deployment size
const totalSize = Object.values(files).reduce((sum, content) => sum + new Blob([content]).size, 0);
const MAX_TOTAL_SIZE = 500 * 1024 * 1024; // 500MB
if (totalSize > MAX_TOTAL_SIZE) {
errors.push(`Total deployment size exceeds 500MB limit`);
}
// Check for common issues
if (Object.keys(files).some((path) => path.includes('node_modules'))) {
warnings.push('Deployment includes node_modules - these should typically be excluded');
}
if (Object.keys(files).some((path) => path.includes('.env'))) {
errors.push('Deployment includes .env file - remove sensitive configuration files');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}

View File

@@ -20,18 +20,6 @@ export default class AmazonBedrockProvider extends BaseProvider {
};
staticModels: ModelInfo[] = [
{
name: 'anthropic.claude-sonnet-4-20250514-v1:0',
label: 'Claude Sonnet 4 (Bedrock)',
provider: 'AmazonBedrock',
maxTokenAllowed: 200000,
},
{
name: 'anthropic.claude-opus-4-1-20250805-v1:0',
label: 'Claude Opus 4.1 (Bedrock)',
provider: 'AmazonBedrock',
maxTokenAllowed: 200000,
},
{
name: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
label: 'Claude 3.5 Sonnet v2 (Bedrock)',

View File

@@ -1,10 +1,10 @@
import { BaseProvider } from '~/lib/modules/llm/base-provider';
import type { ModelInfo } from '~/lib/modules/llm/types';
import type { IProviderSetting } from '~/types/model';
import type { LanguageModelV1 } from 'ai';
import type { IProviderSetting } from '~/types/model';
import { createAnthropic } from '@ai-sdk/anthropic';
export class AnthropicProvider extends BaseProvider {
export default class AnthropicProvider extends BaseProvider {
name = 'Anthropic';
getApiKeyLink = 'https://console.anthropic.com/settings/keys';
@@ -13,50 +13,6 @@ export class AnthropicProvider extends BaseProvider {
};
staticModels: ModelInfo[] = [
/*
* Claude Opus 4.1: Most powerful model for coding and reasoning
* Released August 5, 2025
*/
{
name: 'claude-opus-4-1-20250805',
label: 'Claude Opus 4.1',
provider: 'Anthropic',
maxTokenAllowed: 200000,
maxCompletionTokens: 64000,
supportsSmartAI: false, // Base model without SmartAI
},
{
name: 'claude-opus-4-1-20250805-smartai',
label: 'Claude Opus 4.1 (SmartAI)',
provider: 'Anthropic',
maxTokenAllowed: 200000,
maxCompletionTokens: 64000,
supportsSmartAI: true,
isSmartAIEnabled: true,
},
/*
* Claude Sonnet 4: Hybrid instant/extended response model
* Released May 14, 2025
*/
{
name: 'claude-sonnet-4-20250514',
label: 'Claude Sonnet 4',
provider: 'Anthropic',
maxTokenAllowed: 200000,
maxCompletionTokens: 64000,
supportsSmartAI: false, // Base model without SmartAI
},
{
name: 'claude-sonnet-4-20250514-smartai',
label: 'Claude Sonnet 4 (SmartAI)',
provider: 'Anthropic',
maxTokenAllowed: 200000,
maxCompletionTokens: 64000,
supportsSmartAI: true,
isSmartAIEnabled: true,
},
/*
* Essential fallback models - only the most stable/reliable ones
* Claude 3.5 Sonnet: 200k context, excellent for complex reasoning and coding
@@ -66,17 +22,7 @@ export class AnthropicProvider extends BaseProvider {
label: 'Claude 3.5 Sonnet',
provider: 'Anthropic',
maxTokenAllowed: 200000,
maxCompletionTokens: 8192,
supportsSmartAI: false, // Base model without SmartAI
},
{
name: 'claude-3-5-sonnet-20241022-smartai',
label: 'Claude 3.5 Sonnet (SmartAI)',
provider: 'Anthropic',
maxTokenAllowed: 200000,
maxCompletionTokens: 8192,
supportsSmartAI: true,
isSmartAIEnabled: true,
maxCompletionTokens: 128000,
},
// Claude 3 Haiku: 200k context, fastest and most cost-effective
@@ -85,17 +31,16 @@ export class AnthropicProvider extends BaseProvider {
label: 'Claude 3 Haiku',
provider: 'Anthropic',
maxTokenAllowed: 200000,
maxCompletionTokens: 4096,
supportsSmartAI: false, // Base model without SmartAI
maxCompletionTokens: 128000,
},
// Claude Opus 4: 200k context, 32k output limit (latest flagship model)
{
name: 'claude-3-haiku-20240307-smartai',
label: 'Claude 3 Haiku (SmartAI)',
name: 'claude-opus-4-20250514',
label: 'Claude 4 Opus',
provider: 'Anthropic',
maxTokenAllowed: 200000,
maxCompletionTokens: 4096,
supportsSmartAI: true,
isSmartAIEnabled: true,
maxCompletionTokens: 32000,
},
];
@@ -119,8 +64,7 @@ export class AnthropicProvider extends BaseProvider {
const response = await fetch(`https://api.anthropic.com/v1/models`, {
headers: {
'x-api-key': `${apiKey}`,
['anthropic-version']: '2023-06-01',
['Content-Type']: 'application/json',
'anthropic-version': '2023-06-01',
},
});
@@ -146,21 +90,15 @@ export class AnthropicProvider extends BaseProvider {
contextWindow = 200000; // Claude 3 Sonnet has 200k context
}
// Determine max completion tokens based on model
let maxCompletionTokens = 4096; // default fallback
// Determine completion token limits based on specific model
let maxCompletionTokens = 128000; // default for older Claude 3 models
if (m.id?.includes('claude-sonnet-4') || m.id?.includes('claude-opus-4')) {
maxCompletionTokens = 64000;
} else if (m.id?.includes('claude-3-7-sonnet')) {
maxCompletionTokens = 64000;
} else if (m.id?.includes('claude-3-5-sonnet')) {
maxCompletionTokens = 8192;
} else if (m.id?.includes('claude-3-haiku')) {
maxCompletionTokens = 4096;
} else if (m.id?.includes('claude-3-opus')) {
maxCompletionTokens = 4096;
} else if (m.id?.includes('claude-3-sonnet')) {
maxCompletionTokens = 4096;
if (m.id?.includes('claude-opus-4')) {
maxCompletionTokens = 32000; // Claude 4 Opus: 32K output limit
} else if (m.id?.includes('claude-sonnet-4')) {
maxCompletionTokens = 64000; // Claude 4 Sonnet: 64K output limit
} else if (m.id?.includes('claude-4')) {
maxCompletionTokens = 32000; // Other Claude 4 models: conservative 32K limit
}
return {
@@ -169,7 +107,6 @@ export class AnthropicProvider extends BaseProvider {
provider: this.name,
maxTokenAllowed: contextWindow,
maxCompletionTokens,
supportsSmartAI: true, // All Anthropic models support SmartAI
};
});
}
@@ -180,27 +117,19 @@ export class AnthropicProvider extends BaseProvider {
apiKeys?: Record<string, string>;
providerSettings?: Record<string, IProviderSetting>;
}) => LanguageModelV1 = (options) => {
const { model, serverEnv, apiKeys, providerSettings } = options;
const { apiKey, baseUrl } = this.getProviderBaseUrlAndKey({
const { apiKeys, providerSettings, serverEnv, model } = options;
const { apiKey } = this.getProviderBaseUrlAndKey({
apiKeys,
providerSettings: providerSettings?.[this.name],
providerSettings,
serverEnv: serverEnv as any,
defaultBaseUrlKey: '',
defaultApiTokenKey: 'ANTHROPIC_API_KEY',
});
if (!apiKey) {
throw `Missing API key for ${this.name} provider`;
}
const anthropic = createAnthropic({
apiKey,
baseURL: baseUrl || 'https://api.anthropic.com/v1',
headers: { 'anthropic-beta': 'output-128k-2025-02-19' },
});
// Handle SmartAI variant by using the base model name
const actualModel = model.replace('-smartai', '');
return anthropic(actualModel);
return anthropic(model);
};
}

View File

@@ -31,18 +31,6 @@ export default class OpenRouterProvider extends BaseProvider {
* Essential fallback models - only the most stable/reliable ones
* Claude 3.5 Sonnet via OpenRouter: 200k context
*/
{
name: 'anthropic/claude-sonnet-4-20250514',
label: 'Anthropic: Claude Sonnet 4 (OpenRouter)',
provider: 'OpenRouter',
maxTokenAllowed: 200000,
},
{
name: 'anthropic/claude-opus-4-1-20250805',
label: 'Anthropic: Claude Opus 4.1 (OpenRouter)',
provider: 'OpenRouter',
maxTokenAllowed: 200000,
},
{
name: 'anthropic/claude-3.5-sonnet',
label: 'Claude 3.5 Sonnet',

View File

@@ -17,23 +17,7 @@ export default class OpenAIProvider extends BaseProvider {
* Essential fallback models - only the most stable/reliable ones
* GPT-4o: 128k context, 4k standard output (64k with long output mode)
*/
{
name: 'gpt-4o',
label: 'GPT-4o',
provider: 'OpenAI',
maxTokenAllowed: 128000,
maxCompletionTokens: 4096,
supportsSmartAI: false, // Base model without SmartAI
},
{
name: 'gpt-4o-smartai',
label: 'GPT-4o (SmartAI)',
provider: 'OpenAI',
maxTokenAllowed: 128000,
maxCompletionTokens: 4096,
supportsSmartAI: true,
isSmartAIEnabled: true,
},
{ name: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI', maxTokenAllowed: 128000, maxCompletionTokens: 4096 },
// GPT-4o Mini: 128k context, cost-effective alternative
{
@@ -42,16 +26,6 @@ export default class OpenAIProvider extends BaseProvider {
provider: 'OpenAI',
maxTokenAllowed: 128000,
maxCompletionTokens: 4096,
supportsSmartAI: false, // Base model without SmartAI
},
{
name: 'gpt-4o-mini-smartai',
label: 'GPT-4o Mini (SmartAI)',
provider: 'OpenAI',
maxTokenAllowed: 128000,
maxCompletionTokens: 4096,
supportsSmartAI: true,
isSmartAIEnabled: true,
},
// GPT-3.5-turbo: 16k context, fast and cost-effective
@@ -61,16 +35,6 @@ export default class OpenAIProvider extends BaseProvider {
provider: 'OpenAI',
maxTokenAllowed: 16000,
maxCompletionTokens: 4096,
supportsSmartAI: false, // Base model without SmartAI
},
{
name: 'gpt-3.5-turbo-smartai',
label: 'GPT-3.5 Turbo (SmartAI)',
provider: 'OpenAI',
maxTokenAllowed: 16000,
maxCompletionTokens: 4096,
supportsSmartAI: true,
isSmartAIEnabled: true,
},
// o1-preview: 128k context, 32k output limit (reasoning model)
@@ -80,36 +44,10 @@ export default class OpenAIProvider extends BaseProvider {
provider: 'OpenAI',
maxTokenAllowed: 128000,
maxCompletionTokens: 32000,
supportsSmartAI: false, // Base model without SmartAI
},
{
name: 'o1-preview-smartai',
label: 'o1-preview (SmartAI)',
provider: 'OpenAI',
maxTokenAllowed: 128000,
maxCompletionTokens: 32000,
supportsSmartAI: true,
isSmartAIEnabled: true,
},
// o1-mini: 128k context, 65k output limit (reasoning model)
{
name: 'o1-mini',
label: 'o1-mini',
provider: 'OpenAI',
maxTokenAllowed: 128000,
maxCompletionTokens: 65000,
supportsSmartAI: false, // Base model without SmartAI
},
{
name: 'o1-mini-smartai',
label: 'o1-mini (SmartAI)',
provider: 'OpenAI',
maxTokenAllowed: 128000,
maxCompletionTokens: 65000,
supportsSmartAI: true,
isSmartAIEnabled: true,
},
{ name: 'o1-mini', label: 'o1-mini', provider: 'OpenAI', maxTokenAllowed: 128000, maxCompletionTokens: 65000 },
];
async getDynamicModels(
@@ -187,7 +125,6 @@ export default class OpenAIProvider extends BaseProvider {
provider: this.name,
maxTokenAllowed: Math.min(contextWindow, 128000), // Cap at 128k for safety
maxCompletionTokens,
supportsSmartAI: true, // All OpenAI models support SmartAI
};
});
}
@@ -216,9 +153,6 @@ export default class OpenAIProvider extends BaseProvider {
apiKey,
});
// Handle SmartAI variant by using the base model name
const actualModel = model.replace('-smartai', '');
return openai(actualModel);
return openai(model);
}
}

View File

@@ -1,4 +1,4 @@
import { AnthropicProvider } from './providers/anthropic';
import AnthropicProvider from './providers/anthropic';
import CohereProvider from './providers/cohere';
import DeepseekProvider from './providers/deepseek';
import GoogleProvider from './providers/google';

View File

@@ -11,12 +11,6 @@ export interface ModelInfo {
/** Maximum completion/output tokens - how many tokens the model can generate. If not specified, falls back to provider defaults */
maxCompletionTokens?: number;
/** Indicates if this model supports SmartAI enhanced feedback */
supportsSmartAI?: boolean;
/** Indicates if SmartAI is currently enabled for this model variant */
isSmartAIEnabled?: boolean;
}
export interface ProviderInfo {

View File

@@ -1,241 +0,0 @@
import { createScopedLogger } from '~/utils/logger';
import type { ChatHistoryItem } from './useChatHistory';
import { authStore } from '~/lib/stores/auth';
export interface IUserChatMetadata {
userId: string;
gitUrl?: string;
gitBranch?: string;
netlifySiteId?: string;
}
const logger = createScopedLogger('UserChatHistory');
/**
* Open user-specific database
*/
export async function openUserDatabase(): Promise<IDBDatabase | undefined> {
if (typeof indexedDB === 'undefined') {
console.error('indexedDB is not available in this environment.');
return undefined;
}
const authState = authStore.get();
if (!authState.user?.id) {
console.error('No authenticated user found.');
return undefined;
}
// Use user-specific database name
const dbName = `boltHistory_${authState.user.id}`;
return new Promise((resolve) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('chats')) {
const store = db.createObjectStore('chats', { keyPath: 'id' });
store.createIndex('id', 'id', { unique: true });
store.createIndex('urlId', 'urlId', { unique: true });
store.createIndex('userId', 'userId', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
if (!db.objectStoreNames.contains('snapshots')) {
db.createObjectStore('snapshots', { keyPath: 'chatId' });
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' });
}
if (!db.objectStoreNames.contains('workspaces')) {
const workspaceStore = db.createObjectStore('workspaces', { keyPath: 'id' });
workspaceStore.createIndex('name', 'name', { unique: false });
workspaceStore.createIndex('createdAt', 'createdAt', { unique: false });
}
};
request.onsuccess = (event: Event) => {
resolve((event.target as IDBOpenDBRequest).result);
};
request.onerror = (event: Event) => {
resolve(undefined);
logger.error((event.target as IDBOpenDBRequest).error);
};
});
}
/**
* Get all chats for current user
*/
export async function getUserChats(db: IDBDatabase): Promise<ChatHistoryItem[]> {
const authState = authStore.get();
if (!authState.user?.id) {
return [];
}
return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readonly');
const store = transaction.objectStore('chats');
const request = store.getAll();
request.onsuccess = () => {
// Filter by userId and sort by timestamp
const chats = (request.result as ChatHistoryItem[]).sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
resolve(chats);
};
request.onerror = () => reject(request.error);
});
}
/**
* Save user-specific settings
*/
export async function saveUserSetting(db: IDBDatabase, key: string, value: any): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('settings', 'readwrite');
const store = transaction.objectStore('settings');
const request = store.put({ key, value, updatedAt: new Date().toISOString() });
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Load user-specific settings
*/
export async function loadUserSetting(db: IDBDatabase, key: string): Promise<any | null> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('settings', 'readonly');
const store = transaction.objectStore('settings');
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.value : null);
};
request.onerror = () => reject(request.error);
});
}
/**
* Create a workspace for the user
*/
export interface Workspace {
id: string;
name: string;
description?: string;
createdAt: string;
lastAccessed?: string;
files?: Record<string, any>;
}
export async function createWorkspace(db: IDBDatabase, workspace: Omit<Workspace, 'id'>): Promise<string> {
const authState = authStore.get();
if (!authState.user?.id) {
throw new Error('No authenticated user');
}
const workspaceId = `workspace_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
return new Promise((resolve, reject) => {
const transaction = db.transaction('workspaces', 'readwrite');
const store = transaction.objectStore('workspaces');
const fullWorkspace: Workspace = {
id: workspaceId,
...workspace,
};
const request = store.add(fullWorkspace);
request.onsuccess = () => resolve(workspaceId);
request.onerror = () => reject(request.error);
});
}
/**
* Get user workspaces
*/
export async function getUserWorkspaces(db: IDBDatabase): Promise<Workspace[]> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('workspaces', 'readonly');
const store = transaction.objectStore('workspaces');
const request = store.getAll();
request.onsuccess = () => {
const workspaces = (request.result as Workspace[]).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
resolve(workspaces);
};
request.onerror = () => reject(request.error);
});
}
/**
* Delete a workspace
*/
export async function deleteWorkspace(db: IDBDatabase, workspaceId: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('workspaces', 'readwrite');
const store = transaction.objectStore('workspaces');
const request = store.delete(workspaceId);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Get user statistics
*/
export async function getUserStats(db: IDBDatabase): Promise<{
totalChats: number;
totalWorkspaces: number;
lastActivity?: string;
storageUsed?: number;
}> {
try {
const [chats, workspaces] = await Promise.all([getUserChats(db), getUserWorkspaces(db)]);
// Calculate last activity
let lastActivity: string | undefined;
const allTimestamps = [
...chats.map((c) => c.timestamp),
...workspaces.map((w) => w.lastAccessed || w.createdAt),
].filter(Boolean);
if (allTimestamps.length > 0) {
lastActivity = allTimestamps.sort().reverse()[0];
}
return {
totalChats: chats.length,
totalWorkspaces: workspaces.length,
lastActivity,
};
} catch (error) {
logger.error('Failed to get user stats:', error);
return {
totalChats: 0,
totalWorkspaces: 0,
};
}
}

View File

@@ -1,300 +0,0 @@
import { atom, map } from 'nanostores';
import type { UserProfile } from '~/lib/utils/fileUserStorage';
import Cookies from 'js-cookie';
export interface AuthState {
isAuthenticated: boolean;
user: Omit<UserProfile, 'passwordHash'> | null;
token: string | null;
loading: boolean;
}
// Authentication state store
export const authStore = map<AuthState>({
isAuthenticated: false,
user: null,
token: null,
loading: true,
});
// Remember me preference
export const rememberMeStore = atom<boolean>(false);
// Session timeout tracking
let sessionTimeout: NodeJS.Timeout | null = null;
const SESSION_TIMEOUT = 7 * 24 * 60 * 60 * 1000; // 7 days
/**
* Initialize auth from stored token
*/
export async function initializeAuth(): Promise<void> {
if (typeof window === 'undefined') {
return;
}
authStore.setKey('loading', true);
try {
const token = Cookies.get('auth_token');
if (token) {
// Verify token with backend
const response = await fetch('/api/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const data = (await response.json()) as { user: Omit<UserProfile, 'passwordHash'> };
setAuthState({
isAuthenticated: true,
user: data.user,
token,
loading: false,
});
startSessionTimer();
} else {
// Token is invalid, clear it
clearAuth();
}
} else {
authStore.setKey('loading', false);
}
} catch (error) {
console.error('Failed to initialize auth:', error);
authStore.setKey('loading', false);
}
}
/**
* Set authentication state
*/
export function setAuthState(state: AuthState): void {
authStore.set(state);
if (state.token) {
// Store token in cookie
const cookieOptions = rememberMeStore.get()
? { expires: 7 } // 7 days
: undefined; // Session cookie
Cookies.set('auth_token', state.token, cookieOptions);
// Store user preferences in localStorage
if (state.user) {
localStorage.setItem(`bolt_user_${state.user.id}`, JSON.stringify(state.user.preferences || {}));
}
}
}
/**
* Login user
*/
export async function login(
username: string,
password: string,
rememberMe: boolean = false,
): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = (await response.json()) as {
success?: boolean;
error?: string;
user?: Omit<UserProfile, 'passwordHash'>;
token?: string;
};
if (response.ok) {
rememberMeStore.set(rememberMe);
setAuthState({
isAuthenticated: true,
user: data.user || null,
token: data.token || null,
loading: false,
});
startSessionTimer();
return { success: true };
} else {
return { success: false, error: data.error || 'Login failed' };
}
} catch (error) {
console.error('Login error:', error);
return { success: false, error: 'Network error' };
}
}
/**
* Signup new user
*/
export async function signup(
username: string,
password: string,
firstName: string,
avatar?: string,
): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password, firstName, avatar }),
});
const data = (await response.json()) as {
success?: boolean;
error?: string;
user?: Omit<UserProfile, 'passwordHash'>;
token?: string;
};
if (response.ok) {
setAuthState({
isAuthenticated: true,
user: data.user || null,
token: data.token || null,
loading: false,
});
startSessionTimer();
return { success: true };
} else {
return { success: false, error: data.error || 'Signup failed' };
}
} catch (error) {
console.error('Signup error:', error);
return { success: false, error: 'Network error' };
}
}
/**
* Logout user
*/
export async function logout(): Promise<void> {
const state = authStore.get();
if (state.token) {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
Authorization: `Bearer ${state.token}`,
},
});
} catch (error) {
console.error('Logout error:', error);
}
}
clearAuth();
}
/**
* Clear authentication state
*/
function clearAuth(): void {
authStore.set({
isAuthenticated: false,
user: null,
token: null,
loading: false,
});
Cookies.remove('auth_token');
stopSessionTimer();
// Clear user-specific localStorage
const currentUser = authStore.get().user;
if (currentUser?.id) {
// Keep preferences but clear sensitive data
const prefs = localStorage.getItem(`bolt_user_${currentUser.id}`);
if (prefs) {
try {
const parsed = JSON.parse(prefs);
delete parsed.deploySettings;
delete parsed.githubSettings;
localStorage.setItem(`bolt_user_${currentUser.id}`, JSON.stringify(parsed));
} catch {}
}
}
}
/**
* Start session timer
*/
function startSessionTimer(): void {
stopSessionTimer();
if (!rememberMeStore.get()) {
sessionTimeout = setTimeout(() => {
logout();
if (typeof window !== 'undefined') {
window.location.href = '/auth';
}
}, SESSION_TIMEOUT);
}
}
/**
* Stop session timer
*/
function stopSessionTimer(): void {
if (sessionTimeout) {
clearTimeout(sessionTimeout);
sessionTimeout = null;
}
}
/**
* Update user profile
*/
export async function updateProfile(
updates: Partial<Omit<UserProfile, 'passwordHash' | 'id' | 'username'>>,
): Promise<boolean> {
const state = authStore.get();
if (!state.token || !state.user) {
return false;
}
try {
const response = await fetch('/api/users/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${state.token}`,
},
body: JSON.stringify(updates),
});
if (response.ok) {
const updatedUser = (await response.json()) as Omit<UserProfile, 'passwordHash'>;
authStore.setKey('user', updatedUser);
return true;
}
} catch (error) {
console.error('Failed to update profile:', error);
}
return false;
}
// Initialize auth on load
if (typeof window !== 'undefined') {
initializeAuth();
}

View File

@@ -223,13 +223,10 @@ export class WorkbenchStore {
}
async saveFile(filePath: string) {
console.log(`[WorkbenchStore] saveFile called for: ${filePath}`);
const documents = this.#editorStore.documents.get();
const document = documents[filePath];
if (document === undefined) {
console.warn(`[WorkbenchStore] No document found for: ${filePath}`);
return;
}
@@ -239,39 +236,21 @@ export class WorkbenchStore {
* This is a more complex feature that would be implemented in a future update
*/
try {
console.log(`[WorkbenchStore] Saving to file system: ${filePath}`);
await this.#filesStore.saveFile(filePath, document.value);
console.log(`[WorkbenchStore] File saved successfully: ${filePath}`);
await this.#filesStore.saveFile(filePath, document.value);
const newUnsavedFiles = new Set(this.unsavedFiles.get());
const wasUnsaved = newUnsavedFiles.has(filePath);
newUnsavedFiles.delete(filePath);
const newUnsavedFiles = new Set(this.unsavedFiles.get());
newUnsavedFiles.delete(filePath);
console.log(`[WorkbenchStore] Updating unsaved files:`, {
filePath,
wasUnsaved,
previousCount: this.unsavedFiles.get().size,
newCount: newUnsavedFiles.size,
remainingFiles: Array.from(newUnsavedFiles),
});
this.unsavedFiles.set(newUnsavedFiles);
} catch (error) {
console.error(`[WorkbenchStore] Failed to save file ${filePath}:`, error);
throw error;
}
this.unsavedFiles.set(newUnsavedFiles);
}
async saveCurrentDocument() {
const currentDocument = this.currentDocument.get();
if (currentDocument === undefined) {
console.warn('[WorkbenchStore] No current document to save');
return;
}
console.log(`[WorkbenchStore] Saving current document: ${currentDocument.filePath}`);
await this.saveFile(currentDocument.filePath);
}
@@ -293,14 +272,9 @@ export class WorkbenchStore {
}
async saveAllFiles() {
const filesToSave = Array.from(this.unsavedFiles.get());
console.log(`[WorkbenchStore] saveAllFiles called for ${filesToSave.length} files:`, filesToSave);
for (const filePath of filesToSave) {
for (const filePath of this.unsavedFiles.get()) {
await this.saveFile(filePath);
}
console.log('[WorkbenchStore] saveAllFiles complete. Remaining unsaved:', Array.from(this.unsavedFiles.get()));
}
getFileModifcations() {

View File

@@ -1,86 +0,0 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
// Use a secure secret key (in production, this should be an environment variable)
const JWT_SECRET = process.env.JWT_SECRET || 'bolt-multi-user-secret-key-2024-secure';
const SALT_ROUNDS = 10;
export interface JWTPayload {
userId: string;
username: string;
firstName: string;
exp?: number;
}
/**
* Hash a password using bcrypt
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
/**
* Verify a password against a hash
*/
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* Generate a JWT token
*/
export function generateToken(payload: Omit<JWTPayload, 'exp'>): string {
return jwt.sign(
{
...payload,
exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days
},
JWT_SECRET,
);
}
/**
* Verify and decode a JWT token
*/
export function verifyToken(token: string): JWTPayload | null {
try {
return jwt.verify(token, JWT_SECRET) as JWTPayload;
} catch {
return null;
}
}
/**
* Generate a secure user ID
*/
export function generateUserId(): string {
return `user_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
/**
* Validate password strength
*/
export function validatePassword(password: string): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number');
}
return {
valid: errors.length === 0,
errors,
};
}

View File

@@ -1,338 +0,0 @@
import fs from 'fs/promises';
import path from 'path';
import { generateUserId, hashPassword } from './crypto';
const USERS_DIR = path.join(process.cwd(), '.users');
const USERS_INDEX_FILE = path.join(USERS_DIR, 'users.json');
const USER_DATA_DIR = path.join(USERS_DIR, 'data');
export interface UserProfile {
id: string;
username: string;
firstName: string;
passwordHash: string;
avatar?: string;
createdAt: string;
lastLogin?: string;
preferences: UserPreferences;
}
export interface UserPreferences {
theme: 'light' | 'dark';
deploySettings: {
netlify?: any;
vercel?: any;
};
githubSettings?: any;
workspaceConfig: any;
}
export interface SecurityLog {
timestamp: string;
userId?: string;
username?: string;
action: 'login' | 'logout' | 'signup' | 'delete' | 'error' | 'failed_login';
details: string;
ip?: string;
}
/**
* Initialize the user storage system
*/
export async function initializeUserStorage(): Promise<void> {
try {
// Create directories if they don't exist
await fs.mkdir(USERS_DIR, { recursive: true });
await fs.mkdir(USER_DATA_DIR, { recursive: true });
// Create users index if it doesn't exist
try {
await fs.access(USERS_INDEX_FILE);
} catch {
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users: [] }, null, 2));
}
} catch (error) {
console.error('Failed to initialize user storage:', error);
throw error;
}
}
/**
* Get all users (without passwords)
*/
export async function getAllUsers(): Promise<Omit<UserProfile, 'passwordHash'>[]> {
try {
await initializeUserStorage();
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
const { users } = JSON.parse(data) as { users: UserProfile[] };
return users.map(({ passwordHash, ...user }) => user);
} catch (error) {
console.error('Failed to get users:', error);
return [];
}
}
/**
* Get a user by username
*/
export async function getUserByUsername(username: string): Promise<UserProfile | null> {
try {
await initializeUserStorage();
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
const { users } = JSON.parse(data) as { users: UserProfile[] };
return users.find((u) => u.username === username) || null;
} catch (error) {
console.error('Failed to get user:', error);
return null;
}
}
/**
* Get a user by ID
*/
export async function getUserById(id: string): Promise<UserProfile | null> {
try {
await initializeUserStorage();
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
const { users } = JSON.parse(data) as { users: UserProfile[] };
return users.find((u) => u.id === id) || null;
} catch (error) {
console.error('Failed to get user:', error);
return null;
}
}
/**
* Create a new user
*/
export async function createUser(
username: string,
password: string,
firstName: string,
avatar?: string,
): Promise<UserProfile | null> {
try {
await initializeUserStorage();
// Check if username already exists
const existingUser = await getUserByUsername(username);
if (existingUser) {
throw new Error('Username already exists');
}
// Create new user
const newUser: UserProfile = {
id: generateUserId(),
username,
firstName,
passwordHash: await hashPassword(password),
avatar,
createdAt: new Date().toISOString(),
preferences: {
theme: 'dark',
deploySettings: {},
workspaceConfig: {},
},
};
// Load existing users
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
const { users } = JSON.parse(data) as { users: UserProfile[] };
// Add new user
users.push(newUser);
// Save updated users
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2));
// Create user data directory
const userDataDir = path.join(USER_DATA_DIR, newUser.id);
await fs.mkdir(userDataDir, { recursive: true });
// Log the signup
await logSecurityEvent({
timestamp: new Date().toISOString(),
userId: newUser.id,
username: newUser.username,
action: 'signup',
details: `User ${newUser.username} created successfully`,
});
return newUser;
} catch (error) {
console.error('Failed to create user:', error);
await logSecurityEvent({
timestamp: new Date().toISOString(),
action: 'error',
details: `Failed to create user ${username}: ${error}`,
});
throw error;
}
}
/**
* Update user profile
*/
export async function updateUser(userId: string, updates: Partial<UserProfile>): Promise<boolean> {
try {
await initializeUserStorage();
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
const { users } = JSON.parse(data) as { users: UserProfile[] };
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex === -1) {
return false;
}
// Update user (excluding certain fields)
const { id, username, passwordHash, ...safeUpdates } = updates;
users[userIndex] = {
...users[userIndex],
...safeUpdates,
};
// Save updated users
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2));
return true;
} catch (error) {
console.error('Failed to update user:', error);
return false;
}
}
/**
* Update user's last login time
*/
export async function updateLastLogin(userId: string): Promise<void> {
await updateUser(userId, { lastLogin: new Date().toISOString() });
}
/**
* Delete a user
*/
export async function deleteUser(userId: string): Promise<boolean> {
try {
await initializeUserStorage();
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
const { users } = JSON.parse(data) as { users: UserProfile[] };
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex === -1) {
return false;
}
const deletedUser = users[userIndex];
// Remove user from list
users.splice(userIndex, 1);
// Save updated users
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2));
// Delete user data directory
const userDataDir = path.join(USER_DATA_DIR, userId);
try {
await fs.rm(userDataDir, { recursive: true, force: true });
} catch (error) {
console.warn(`Failed to delete user data directory: ${error}`);
}
// Log the deletion
await logSecurityEvent({
timestamp: new Date().toISOString(),
userId,
username: deletedUser.username,
action: 'delete',
details: `User ${deletedUser.username} deleted`,
});
return true;
} catch (error) {
console.error('Failed to delete user:', error);
return false;
}
}
/**
* Save user-specific data
*/
export async function saveUserData(userId: string, key: string, data: any): Promise<void> {
try {
const userDataDir = path.join(USER_DATA_DIR, userId);
await fs.mkdir(userDataDir, { recursive: true });
const filePath = path.join(userDataDir, `${key}.json`);
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
} catch (error) {
console.error(`Failed to save user data for ${userId}:`, error);
throw error;
}
}
/**
* Load user-specific data
*/
export async function loadUserData(userId: string, key: string): Promise<any | null> {
try {
const filePath = path.join(USER_DATA_DIR, userId, `${key}.json`);
const data = await fs.readFile(filePath, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
}
/**
* Log security events
*/
export async function logSecurityEvent(event: SecurityLog): Promise<void> {
try {
const logFile = path.join(USERS_DIR, 'security.log');
const logEntry = `${JSON.stringify(event)}\n`;
await fs.appendFile(logFile, logEntry);
} catch (error) {
console.error('Failed to log security event:', error);
}
}
/**
* Get security logs
*/
export async function getSecurityLogs(limit: number = 100): Promise<SecurityLog[]> {
try {
const logFile = path.join(USERS_DIR, 'security.log');
const data = await fs.readFile(logFile, 'utf-8');
const logs = data
.trim()
.split('\n')
.filter((line) => line)
.map((line) => {
try {
return JSON.parse(line) as SecurityLog;
} catch {
return null;
}
})
.filter(Boolean) as SecurityLog[];
return logs.slice(-limit).reverse();
} catch {
return [];
}
}

View File

@@ -1,173 +1,28 @@
import { json, type MetaFunction } from '@remix-run/cloudflare';
import { useLoaderData } from '@remix-run/react';
import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/header/Header';
import BackgroundRays from '~/components/ui/BackgroundRays';
import { useEffect, useState } from 'react';
import { providersStore } from '~/lib/stores/settings';
import { authStore } from '~/lib/stores/auth';
import { useNavigate } from '@remix-run/react';
import { motion, AnimatePresence } from 'framer-motion';
export const meta: MetaFunction = () => {
return [
{ title: 'Bolt.gives' },
{
name: 'description',
content: 'Build web applications with AI assistance - Enhanced fork with advanced features',
},
];
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
};
export const loader = ({ context }: { context: any }) => {
// Check which local providers are configured
const configuredProviders: string[] = [];
// Check Ollama
if (context.cloudflare?.env?.OLLAMA_API_BASE_URL || process.env?.OLLAMA_API_BASE_URL) {
configuredProviders.push('Ollama');
}
// Check LMStudio
if (context.cloudflare?.env?.LMSTUDIO_API_BASE_URL || process.env?.LMSTUDIO_API_BASE_URL) {
configuredProviders.push('LMStudio');
}
// Check OpenAILike
if (context.cloudflare?.env?.OPENAI_LIKE_API_BASE_URL || process.env?.OPENAI_LIKE_API_BASE_URL) {
configuredProviders.push('OpenAILike');
}
return json({ configuredProviders });
};
export const loader = () => json({});
/**
* Landing page component for Bolt.gives
* Enhanced fork with multi-user authentication, advanced features, and provider auto-detection
* Landing page component for Bolt
* Note: Settings functionality should ONLY be accessed through the sidebar menu.
* Do not add settings button/panel to this landing page as it was intentionally removed
* to keep the UI clean and consistent with the design system.
*/
export default function Index() {
const data = useLoaderData<{ configuredProviders: string[] }>();
const [showMultiUserBanner, setShowMultiUserBanner] = useState(false);
const navigate = useNavigate();
useEffect(() => {
// Enable configured providers if they haven't been manually configured yet
if (data?.configuredProviders && data.configuredProviders.length > 0) {
const savedSettings = localStorage.getItem('provider_settings');
if (!savedSettings) {
// No saved settings, so enable the configured providers
const currentProviders = providersStore.get();
data.configuredProviders.forEach((providerName) => {
if (currentProviders[providerName]) {
providersStore.setKey(providerName, {
...currentProviders[providerName],
settings: {
...currentProviders[providerName].settings,
enabled: true,
},
});
}
});
// Save to localStorage so this only happens once
localStorage.setItem('provider_settings', JSON.stringify(providersStore.get()));
}
}
}, [data?.configuredProviders]);
useEffect(() => {
// Check if user is authenticated
const authState = authStore.get();
// Show banner only if not authenticated and hasn't been dismissed
const bannerDismissed = localStorage.getItem('multiUserBannerDismissed');
if (!authState.isAuthenticated && !bannerDismissed) {
setTimeout(() => setShowMultiUserBanner(true), 2000);
}
}, []);
const handleActivateMultiUser = () => {
navigate('/auth');
};
const handleDismissBanner = () => {
setShowMultiUserBanner(false);
localStorage.setItem('multiUserBannerDismissed', 'true');
};
return (
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
<BackgroundRays />
<Header />
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
{/* Optional Multi-User Activation Banner */}
<AnimatePresence>
{showMultiUserBanner && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
className="fixed bottom-6 right-6 max-w-sm z-50"
>
<div className="bg-bolt-elements-background-depth-2 backdrop-blur-xl rounded-xl border border-bolt-elements-borderColor shadow-2xl p-4">
<button
onClick={handleDismissBanner}
className="absolute top-2 right-2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary transition-colors"
aria-label="Close"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-center shadow-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-bolt-elements-textPrimary mb-1">
Unlock Multi-User Features
</h3>
<p className="text-xs text-bolt-elements-textSecondary mb-3">
Save your projects, personalized settings, and collaborate with workspace isolation.
</p>
<div className="flex gap-2">
<button
onClick={handleActivateMultiUser}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 text-white hover:from-blue-600 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl"
>
Activate Now
</button>
<button
onClick={handleDismissBanner}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover transition-all"
>
Continue as Guest
</button>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,351 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate } from '@remix-run/react';
import { motion, AnimatePresence } from 'framer-motion';
import { useStore } from '@nanostores/react';
import { authStore } from '~/lib/stores/auth';
import { ProtectedRoute } from '~/components/auth/ProtectedRoute';
import { classNames } from '~/utils/classNames';
interface User {
id: string;
username: string;
firstName: string;
avatar?: string;
createdAt: string;
lastLogin?: string;
}
export default function UserManagement() {
const navigate = useNavigate();
const authState = useStore(authStore);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const response = await fetch('/api/users', {
headers: {
Authorization: `Bearer ${authState.token}`,
},
});
if (response.ok) {
const data = (await response.json()) as { users: User[] };
setUsers(data.users);
}
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
setLoading(false);
}
};
const handleDeleteUser = async () => {
if (!selectedUser) {
return;
}
setDeleting(true);
try {
const response = await fetch(`/api/users/${selectedUser.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authState.token}`,
},
});
if (response.ok) {
setUsers(users.filter((u) => u.id !== selectedUser.id));
setShowDeleteModal(false);
setSelectedUser(null);
}
} catch (error) {
console.error('Failed to delete user:', error);
} finally {
setDeleting(false);
}
};
const filteredUsers = users.filter(
(user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.firstName.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<ProtectedRoute>
<div className="min-h-screen bg-bolt-elements-background-depth-1">
{/* Header */}
<header className="border-b border-bolt-elements-borderColor bg-bolt-elements-background-depth-2">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/')}
className="p-2 rounded-lg hover:bg-bolt-elements-background-depth-3 transition-colors"
>
<span className="i-ph:arrow-left text-xl text-bolt-elements-textPrimary" />
</button>
<div>
<h1 className="text-xl font-bold text-bolt-elements-textPrimary">User Management</h1>
<p className="text-sm text-bolt-elements-textSecondary">Manage system users</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<input
type="text"
placeholder="Search users..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={classNames(
'w-64 px-4 py-2 pl-10 rounded-lg',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-accent-500',
)}
/>
<span className="absolute left-3 top-2.5 i-ph:magnifying-glass text-bolt-elements-textTertiary" />
</div>
<button
onClick={() => navigate('/auth')}
className={classNames(
'px-4 py-2 rounded-lg',
'bg-accent-500 text-white',
'hover:bg-accent-600',
'transition-colors',
'flex items-center gap-2',
)}
>
<span className="i-ph:plus text-lg" />
<span>Add User</span>
</button>
</div>
</div>
</div>
</header>
{/* User Stats */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
<p className="text-sm text-bolt-elements-textSecondary mb-1">Total Users</p>
<p className="text-2xl font-bold text-bolt-elements-textPrimary">{users.length}</p>
</div>
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
<p className="text-sm text-bolt-elements-textSecondary mb-1">Active Today</p>
<p className="text-2xl font-bold text-green-500">
{
users.filter((u) => {
if (!u.lastLogin) {
return false;
}
const lastLogin = new Date(u.lastLogin);
const today = new Date();
return lastLogin.toDateString() === today.toDateString();
}).length
}
</p>
</div>
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
<p className="text-sm text-bolt-elements-textSecondary mb-1">New This Week</p>
<p className="text-2xl font-bold text-blue-500">
{
users.filter((u) => {
const created = new Date(u.createdAt);
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return created > weekAgo;
}).length
}
</p>
</div>
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
<p className="text-sm text-bolt-elements-textSecondary mb-1">Storage Used</p>
<p className="text-2xl font-bold text-bolt-elements-textPrimary">0 MB</p>
</div>
</div>
</div>
{/* User List */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
{loading ? (
<div className="flex justify-center py-12">
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
</div>
) : filteredUsers.length === 0 ? (
<div className="text-center py-12">
<span className="i-ph:users text-4xl text-bolt-elements-textTertiary mb-4" />
<p className="text-bolt-elements-textSecondary">
{searchQuery ? 'No users found matching your search' : 'No users yet'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence>
{filteredUsers.map((user, index) => (
<motion.div
key={user.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className={classNames(
'bg-bolt-elements-background-depth-2 rounded-lg p-6',
'border border-bolt-elements-borderColor',
'hover:shadow-lg transition-all',
user.id === authState.user?.id ? 'ring-2 ring-accent-500' : '',
)}
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-bolt-elements-background-depth-3 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
{user.avatar ? (
<img src={user.avatar} alt={user.firstName} className="w-full h-full object-cover" />
) : (
<span className="text-lg font-medium text-bolt-elements-textPrimary">
{user.firstName[0].toUpperCase()}
</span>
)}
</div>
<div>
<p className="font-medium text-bolt-elements-textPrimary">
{user.firstName}
{user.id === authState.user?.id && (
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-accent-500/20 text-accent-500">
You
</span>
)}
</p>
<p className="text-sm text-bolt-elements-textSecondary">@{user.username}</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
className="p-1 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
title="Edit user"
>
<span className="i-ph:pencil text-bolt-elements-textSecondary" />
</button>
{user.id !== authState.user?.id && (
<button
onClick={() => {
setSelectedUser(user);
setShowDeleteModal(true);
}}
className="p-1 rounded hover:bg-red-500/10 transition-colors"
title="Delete user"
>
<span className="i-ph:trash text-red-500" />
</button>
)}
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
<span className="i-ph:calendar-blank" />
<span>Joined {new Date(user.createdAt).toLocaleDateString()}</span>
</div>
{user.lastLogin && (
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
<span className="i-ph:clock" />
<span>Last active {new Date(user.lastLogin).toLocaleDateString()}</span>
</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
{/* Delete Confirmation Modal */}
<AnimatePresence>
{showDeleteModal && selectedUser && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={() => !deleting && setShowDeleteModal(false)}
>
<motion.div
initial={{ scale: 0.95 }}
animate={{ scale: 1 }}
exit={{ scale: 0.95 }}
className="bg-bolt-elements-background-depth-2 rounded-lg p-6 max-w-md w-full border border-bolt-elements-borderColor"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-bold text-bolt-elements-textPrimary mb-2">Delete User</h2>
<p className="text-bolt-elements-textSecondary mb-6">
Are you sure you want to delete{' '}
<span className="font-medium text-bolt-elements-textPrimary">@{selectedUser.username}</span>? This
action cannot be undone and will permanently remove all user data.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteModal(false)}
disabled={deleting}
className={classNames(
'flex-1 px-4 py-2 rounded-lg',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-3',
'disabled:opacity-50',
'transition-colors',
)}
>
Cancel
</button>
<button
onClick={handleDeleteUser}
disabled={deleting}
className={classNames(
'flex-1 px-4 py-2 rounded-lg',
'bg-red-500 text-white',
'hover:bg-red-600',
'disabled:opacity-50',
'transition-colors',
'flex items-center justify-center gap-2',
)}
>
{deleting ? (
<>
<span className="i-svg-spinners:3-dots-scale" />
Deleting...
</>
) : (
<>
<span className="i-ph:trash" />
Delete User
</>
)}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</ProtectedRoute>
);
}

View File

@@ -1,92 +0,0 @@
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { getUserByUsername, updateLastLogin, logSecurityEvent } from '~/lib/utils/fileUserStorage';
import { verifyPassword, generateToken } from '~/lib/utils/crypto';
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
const body = (await request.json()) as { username?: string; password?: string };
const { username, password } = body;
if (!username || !password) {
return json({ error: 'Username and password are required' }, { status: 400 });
}
// Get user from storage
const user = await getUserByUsername(username);
if (!user) {
// Log failed login attempt
await logSecurityEvent({
timestamp: new Date().toISOString(),
username,
action: 'failed_login',
details: `Failed login attempt for non-existent user: ${username}`,
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
return json({ error: 'Invalid username or password' }, { status: 401 });
}
// Verify password
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
// Log failed login attempt
await logSecurityEvent({
timestamp: new Date().toISOString(),
userId: user.id,
username: user.username,
action: 'failed_login',
details: `Failed login attempt with incorrect password`,
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
return json({ error: 'Invalid username or password' }, { status: 401 });
}
// Update last login time
await updateLastLogin(user.id);
// Generate JWT token
const token = generateToken({
userId: user.id,
username: user.username,
firstName: user.firstName,
});
// Log successful login
await logSecurityEvent({
timestamp: new Date().toISOString(),
userId: user.id,
username: user.username,
action: 'login',
details: 'Successful login',
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
// Return user data without password
const { passwordHash, ...userWithoutPassword } = user;
return json({
success: true,
user: userWithoutPassword,
token,
});
} catch (error) {
console.error('Login error:', error);
await logSecurityEvent({
timestamp: new Date().toISOString(),
action: 'error',
details: `Login error: ${error}`,
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,37 +0,0 @@
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { verifyToken } from '~/lib/utils/crypto';
import { logSecurityEvent } from '~/lib/utils/fileUserStorage';
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
// Get token from Authorization header
const authHeader = request.headers.get('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const payload = verifyToken(token);
if (payload) {
// Log logout event
await logSecurityEvent({
timestamp: new Date().toISOString(),
userId: payload.userId,
username: payload.username,
action: 'logout',
details: 'User logged out',
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
}
}
return json({ success: true });
} catch (error) {
console.error('Logout error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,93 +0,0 @@
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { createUser, getUserByUsername, logSecurityEvent } from '~/lib/utils/fileUserStorage';
import { validatePassword, generateToken } from '~/lib/utils/crypto';
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
const body = (await request.json()) as {
username?: string;
password?: string;
firstName?: string;
avatar?: string;
};
const { username, password, firstName, avatar } = body;
// Validate required fields
if (!username || !password || !firstName) {
return json({ error: 'Username, password, and first name are required' }, { status: 400 });
}
// Validate username format
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
return json(
{
error: 'Username must be 3-20 characters and contain only letters, numbers, and underscores',
},
{ status: 400 },
);
}
// Validate password strength
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return json({ error: passwordValidation.errors.join('. ') }, { status: 400 });
}
// Check if username already exists
const existingUser = await getUserByUsername(username);
if (existingUser) {
return json({ error: 'Username already exists' }, { status: 400 });
}
// Create new user
const user = await createUser(username, password, firstName, avatar);
if (!user) {
return json({ error: 'Failed to create user' }, { status: 500 });
}
// Generate JWT token
const token = generateToken({
userId: user.id,
username: user.username,
firstName: user.firstName,
});
// Log successful signup
await logSecurityEvent({
timestamp: new Date().toISOString(),
userId: user.id,
username: user.username,
action: 'signup',
details: 'New user registration',
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
// Return user data without password
const { passwordHash, ...userWithoutPassword } = user;
return json({
success: true,
user: userWithoutPassword,
token,
});
} catch (error) {
console.error('Signup error:', error);
await logSecurityEvent({
timestamp: new Date().toISOString(),
action: 'error',
details: `Signup error: ${error}`,
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,44 +0,0 @@
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { verifyToken } from '~/lib/utils/crypto';
import { getUserById } from '~/lib/utils/fileUserStorage';
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
// Get token from Authorization header
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return json({ error: 'No token provided' }, { status: 401 });
}
const token = authHeader.substring(7);
const payload = verifyToken(token);
if (!payload) {
return json({ error: 'Invalid token' }, { status: 401 });
}
// Get user from storage
const user = await getUserById(payload.userId);
if (!user) {
return json({ error: 'User not found' }, { status: 404 });
}
// Return user data without password
const { passwordHash, ...userWithoutPassword } = user;
return json({
success: true,
user: userWithoutPassword,
});
} catch (error) {
console.error('Token verification error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -13,7 +13,6 @@ import { createSummary } from '~/lib/.server/llm/create-summary';
import { extractPropertiesFromMessage } from '~/lib/.server/llm/utils';
import type { DesignScheme } from '~/types/design-scheme';
import { MCPService } from '~/lib/services/mcpService';
import { StreamRecoveryManager } from '~/lib/.server/llm/stream-recovery';
export async function action(args: ActionFunctionArgs) {
return chatAction(args);
@@ -75,22 +74,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
const encoder: TextEncoder = new TextEncoder();
let progressCounter: number = 1;
// Initialize stream recovery manager
const recovery = new StreamRecoveryManager({
maxRetries: 3,
retryDelay: 2000,
timeout: 45000, // 45 seconds timeout
onTimeout: () => {
logger.warn('Stream timeout detected - attempting recovery');
},
onRetry: (attempt) => {
logger.info(`Stream recovery attempt ${attempt}`);
},
onError: (error) => {
logger.error('Stream error in recovery:', error);
},
});
try {
const mcpService = MCPService.getInstance();
const totalMessageContent = messages.reduce((acc, message) => acc + message.content, '');
@@ -330,77 +313,28 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
});
(async () => {
try {
recovery.startMonitoring();
for await (const part of result.fullStream) {
if (part.type === 'error') {
const error: any = part.error;
logger.error('Streaming error:', error);
let lastActivityTime = Date.now();
const activityCheckInterval = 5000; // Check every 5 seconds
// Set up activity monitoring
const activityChecker = setInterval(() => {
const timeSinceLastActivity = Date.now() - lastActivityTime;
if (timeSinceLastActivity > 30000) {
logger.warn(`No stream activity for ${timeSinceLastActivity}ms`);
// Attempt to recover if stream appears stuck
recovery.attemptRecovery();
// Enhanced error handling for common streaming issues
if (error.message?.includes('Invalid JSON response')) {
logger.error('Invalid JSON response detected - likely malformed API response');
} else if (error.message?.includes('token')) {
logger.error('Token-related error detected - possible token limit exceeded');
}
}, activityCheckInterval);
for await (const part of result.fullStream) {
// Record activity
lastActivityTime = Date.now();
recovery.recordActivity();
if (part.type === 'error') {
const error: any = part.error;
logger.error('Streaming error:', error);
// Enhanced error handling for common streaming issues
if (error.message?.includes('Invalid JSON response')) {
logger.error('Invalid JSON response detected - likely malformed API response');
} else if (error.message?.includes('token')) {
logger.error('Token-related error detected - possible token limit exceeded');
}
// Attempt recovery for certain errors
const canRecover = await recovery.handleError(error);
if (!canRecover) {
clearInterval(activityChecker);
recovery.stop();
return;
}
}
return;
}
// Clean up
clearInterval(activityChecker);
recovery.stop();
} catch (streamError) {
logger.error('Fatal stream error:', streamError);
recovery.stop();
throw streamError;
}
})();
result.mergeIntoDataStream(dataStream);
},
onError: (error: any) => {
// Stop recovery manager on error
recovery.stop();
// Provide more specific error messages for common issues
const errorMessage = error.message || 'Unknown error';
// Log detailed error for debugging
logger.error('Chat API error:', {
message: errorMessage,
stack: error.stack,
code: error.code,
});
if (errorMessage.includes('model') && errorMessage.includes('not found')) {
return 'Custom error: Invalid model selected. Please check that the model name is correct and available.';
}
@@ -426,11 +360,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
}
if (errorMessage.includes('network') || errorMessage.includes('timeout')) {
return 'Custom error: Network error or timeout. The connection was interrupted. Please try again or switch to a different AI model.';
}
if (errorMessage.includes('stream') || errorMessage.includes('hang')) {
return 'Custom error: The conversation stream was interrupted. Please refresh the page and try again.';
return 'Custom error: Network error. Please check your internet connection and try again.';
}
return `Custom error: ${errorMessage}`;
@@ -473,32 +403,17 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
}),
);
// Set up cleanup for recovery manager
const cleanupStream = dataStream.pipeThrough(
new TransformStream({
flush() {
recovery.stop();
},
}),
);
return new Response(cleanupStream, {
return new Response(dataStream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
Connection: 'keep-alive',
'Cache-Control': 'no-cache',
'Text-Encoding': 'chunked',
'X-Accel-Buffering': 'no', // Disable nginx buffering
},
});
} catch (error: any) {
logger.error('Fatal error in chat API:', error);
// Ensure recovery manager is stopped on error
if (typeof recovery !== 'undefined') {
recovery.stop();
}
logger.error(error);
const errorResponse = {
error: true,

View File

@@ -8,7 +8,6 @@ interface ModelsResponse {
modelList: ModelInfo[];
providers: ProviderInfo[];
defaultProvider: ProviderInfo;
configuredProviders?: string[];
}
let cachedProviders: ProviderInfo[] | null = null;
@@ -83,28 +82,9 @@ export async function loader({
});
}
// Check which local providers are configured in environment
const configuredProviders: string[] = [];
// Check Ollama
if (context.cloudflare?.env?.OLLAMA_API_BASE_URL || process.env?.OLLAMA_API_BASE_URL) {
configuredProviders.push('Ollama');
}
// Check LMStudio
if (context.cloudflare?.env?.LMSTUDIO_API_BASE_URL || process.env?.LMSTUDIO_API_BASE_URL) {
configuredProviders.push('LMStudio');
}
// Check OpenAILike
if (context.cloudflare?.env?.OPENAI_LIKE_API_BASE_URL || process.env?.OPENAI_LIKE_API_BASE_URL) {
configuredProviders.push('OpenAILike');
}
return json<ModelsResponse>({
modelList,
providers,
defaultProvider,
configuredProviders,
});
}

View File

@@ -1,239 +0,0 @@
/**
* Netlify Quick Deploy API Endpoint
* Contributed by Keoma Wright
*
* This endpoint handles quick deployments to Netlify without requiring authentication,
* using Netlify's drop API for instant deployment.
*/
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
import crypto from 'crypto';
interface QuickDeployRequestBody {
files: Record<string, string>;
chatId: string;
framework?: string;
}
// Use environment variable or fallback to public token for quick deploys
const NETLIFY_QUICK_DEPLOY_TOKEN = process.env.NETLIFY_QUICK_DEPLOY_TOKEN || '';
export async function action({ request }: ActionFunctionArgs) {
try {
const { files, chatId, framework } = (await request.json()) as QuickDeployRequestBody;
if (!files || Object.keys(files).length === 0) {
return json({ error: 'No files to deploy' }, { status: 400 });
}
// Generate a unique site name
const siteName = `bolt-quick-${chatId.substring(0, 8)}-${Date.now()}`;
// Prepare files for Netlify Drop API
const deployFiles: Record<string, string> = {};
// Add index.html if it doesn't exist (for static sites)
if (!files['/index.html'] && !files['index.html']) {
// Check if there's any HTML file
const htmlFile = Object.keys(files).find((f) => f.endsWith('.html'));
if (!htmlFile) {
// Create a basic index.html
deployFiles['/index.html'] = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${framework || 'Bolt'} App</title>
</head>
<body>
<div id="root"></div>
<script>
// Check if there's a main.js or app.js
const scripts = ${JSON.stringify(Object.keys(files).filter((f) => f.endsWith('.js')))};
if (scripts.length > 0) {
const script = document.createElement('script');
script.src = scripts[0];
document.body.appendChild(script);
}
</script>
</body>
</html>`;
}
}
// Process and normalize file paths
for (const [filePath, content] of Object.entries(files)) {
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
deployFiles[normalizedPath] = content;
}
// Use Netlify's API to create a new site and deploy
let siteId: string | undefined;
let deployUrl: string | undefined;
if (NETLIFY_QUICK_DEPLOY_TOKEN) {
// If we have a token, use the authenticated API
try {
// Create a new site
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
headers: {
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: siteName,
custom_domain: null,
}),
});
if (createSiteResponse.ok) {
const site = (await createSiteResponse.json()) as any;
siteId = site.id;
// Create file digests for deployment
const fileDigests: Record<string, string> = {};
for (const [path, content] of Object.entries(deployFiles)) {
const hash = crypto.createHash('sha1').update(content).digest('hex');
fileDigests[path] = hash;
}
// Create deployment
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/deploys`, {
method: 'POST',
headers: {
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: fileDigests,
async: false,
draft: false,
}),
});
if (deployResponse.ok) {
const deploy = (await deployResponse.json()) as any;
// Upload files
for (const [path, content] of Object.entries(deployFiles)) {
await fetch(`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${path}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
'Content-Type': 'application/octet-stream',
},
body: content,
});
}
deployUrl = deploy.ssl_url || deploy.url || `https://${siteName}.netlify.app`;
}
}
} catch (error) {
console.error('Error with authenticated deployment:', error);
}
}
// Fallback to Netlify Drop (no authentication required)
if (!deployUrl) {
// Create a form data with files
const formData = new FormData();
// Add each file to the form data
for (const [path, content] of Object.entries(deployFiles)) {
const blob = new Blob([content], { type: 'text/plain' });
const fileName = path.startsWith('/') ? path.substring(1) : path;
formData.append('file', blob, fileName);
}
// Deploy using Netlify Drop API (no auth required)
const dropResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
body: formData,
});
if (dropResponse.ok) {
const dropData = (await dropResponse.json()) as any;
siteId = dropData.id;
deployUrl = dropData.ssl_url || dropData.url || `https://${dropData.subdomain}.netlify.app`;
} else {
// Try alternative deployment method
const zipContent = await createZipArchive(deployFiles);
const zipResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/zip',
},
body: zipContent,
});
if (zipResponse.ok) {
const zipData = (await zipResponse.json()) as any;
siteId = zipData.id;
deployUrl = zipData.ssl_url || zipData.url;
} else {
throw new Error('Failed to deploy to Netlify');
}
}
}
if (!deployUrl) {
return json({ error: 'Deployment failed - could not get deployment URL' }, { status: 500 });
}
return json({
success: true,
url: deployUrl,
siteId,
siteName,
});
} catch (error) {
console.error('Quick deploy error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Deployment failed',
details: error instanceof Error ? error.stack : undefined,
},
{ status: 500 },
);
}
}
// Helper function to create a simple ZIP archive (minimal implementation)
async function createZipArchive(files: Record<string, string>): Promise<ArrayBuffer> {
// This is a simplified ZIP creation - in production, use a proper ZIP library
const encoder = new TextEncoder();
const parts: Uint8Array[] = [];
// For simplicity, we'll create a tar-like format
for (const [path, content] of Object.entries(files)) {
const pathBytes = encoder.encode(path);
const contentBytes = encoder.encode(content);
// Simple header: path length (4 bytes) + content length (4 bytes)
const header = new Uint8Array(8);
new DataView(header.buffer).setUint32(0, pathBytes.length, true);
new DataView(header.buffer).setUint32(4, contentBytes.length, true);
parts.push(header);
parts.push(pathBytes);
parts.push(contentBytes);
}
// Combine all parts
const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return result.buffer;
}

View File

@@ -1,49 +0,0 @@
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { verifyToken } from '~/lib/utils/crypto';
import { deleteUser } from '~/lib/utils/fileUserStorage';
export async function action({ request, params }: ActionFunctionArgs) {
try {
const { id } = params;
if (!id) {
return json({ error: 'User ID is required' }, { status: 400 });
}
// Verify authentication
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.substring(7);
const payload = verifyToken(token);
if (!payload) {
return json({ error: 'Invalid token' }, { status: 401 });
}
// Prevent users from deleting themselves
if (payload.userId === id) {
return json({ error: 'Cannot delete your own account' }, { status: 400 });
}
if (request.method === 'DELETE') {
// Delete the user
const success = await deleteUser(id);
if (success) {
return json({ success: true });
} else {
return json({ error: 'User not found' }, { status: 404 });
}
}
return json({ error: 'Method not allowed' }, { status: 405 });
} catch (error) {
console.error('User operation error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,30 +0,0 @@
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { verifyToken } from '~/lib/utils/crypto';
import { getAllUsers } from '~/lib/utils/fileUserStorage';
export async function loader({ request }: LoaderFunctionArgs) {
try {
// Verify authentication
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.substring(7);
const payload = verifyToken(token);
if (!payload) {
return json({ error: 'Invalid token' }, { status: 401 });
}
// Get all users (without passwords)
const users = await getAllUsers();
return json({ users });
} catch (error) {
console.error('Failed to fetch users:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,422 +0,0 @@
import { useState } from 'react';
import { useNavigate } from '@remix-run/react';
import { motion, AnimatePresence } from 'framer-motion';
import { login, signup } from '~/lib/stores/auth';
import { validatePassword } from '~/lib/utils/crypto';
import { classNames } from '~/utils/classNames';
export default function AuthPage() {
const navigate = useNavigate();
const [mode, setMode] = useState<'login' | 'signup'>('login');
const [formData, setFormData] = useState({
username: '',
password: '',
firstName: '',
confirmPassword: '',
rememberMe: false,
});
const [avatar, setAvatar] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
// Clear error for this field
setErrors((prev) => ({ ...prev, [name]: '' }));
};
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setAvatar(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
setLoading(true);
try {
if (mode === 'signup') {
// Validate form
const validationErrors: Record<string, string> = {};
if (!formData.username) {
validationErrors.username = 'Username is required';
}
if (!formData.firstName) {
validationErrors.firstName = 'First name is required';
}
const passwordValidation = validatePassword(formData.password);
if (!passwordValidation.valid) {
validationErrors.password = passwordValidation.errors[0];
}
if (formData.password !== formData.confirmPassword) {
validationErrors.confirmPassword = 'Passwords do not match';
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
setLoading(false);
return;
}
const result = await signup(formData.username, formData.password, formData.firstName, avatar);
if (result.success) {
navigate('/');
} else {
setErrors({ general: result.error || 'Signup failed' });
}
} else {
const result = await login(formData.username, formData.password, formData.rememberMe);
if (result.success) {
navigate('/');
} else {
setErrors({ general: result.error || 'Invalid username or password' });
}
}
} catch {
setErrors({ general: 'An error occurred. Please try again.' });
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden">
{/* Animated gradient background */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 via-purple-600 to-pink-600">
<div className="absolute inset-0 bg-black/30" />
<motion.div
className="absolute inset-0 opacity-30"
animate={{
background: [
'radial-gradient(circle at 20% 80%, #3b82f6 0%, transparent 50%)',
'radial-gradient(circle at 80% 20%, #a855f7 0%, transparent 50%)',
'radial-gradient(circle at 40% 40%, #ec4899 0%, transparent 50%)',
'radial-gradient(circle at 20% 80%, #3b82f6 0%, transparent 50%)',
],
}}
transition={{ duration: 10, repeat: Infinity }}
/>
</div>
{/* Logo and Title */}
<div className="absolute top-8 left-8 z-20">
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
<span className="text-2xl"></span>
</div>
<div>
<h1 className="text-2xl font-bold text-white">bolt.diy</h1>
<p className="text-sm text-white/70">Multi-User Edition</p>
</div>
</motion.div>
</div>
{/* Auth Card */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="relative z-10 w-full max-w-md mx-4"
>
<div className="bg-white/10 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 overflow-hidden">
{/* Tab Header */}
<div className="flex relative bg-white/5">
<button
onClick={() => setMode('login')}
className={classNames(
'flex-1 py-4 text-center font-semibold transition-all',
mode === 'login'
? 'text-white bg-gradient-to-r from-blue-500/20 to-purple-600/20'
: 'text-white/70 hover:text-white hover:bg-white/5',
)}
>
Sign In
</button>
<button
onClick={() => setMode('signup')}
className={classNames(
'flex-1 py-4 text-center font-semibold transition-all',
mode === 'signup'
? 'text-white bg-gradient-to-r from-blue-500/20 to-purple-600/20'
: 'text-white/70 hover:text-white hover:bg-white/5',
)}
>
Sign Up
</button>
{/* Sliding indicator */}
<motion.div
className="absolute bottom-0 h-1 bg-gradient-to-r from-blue-500 to-purple-600"
initial={false}
animate={{
x: mode === 'login' ? '0%' : '100%',
width: '50%',
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
</div>
{/* Form Content */}
<div className="p-8">
<AnimatePresence mode="wait">
<motion.form
key={mode}
initial={{ opacity: 0, x: mode === 'login' ? -20 : 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: mode === 'login' ? 20 : -20 }}
transition={{ duration: 0.3 }}
onSubmit={handleSubmit}
className="space-y-6"
>
{/* Avatar Upload (Signup only) */}
{mode === 'signup' && (
<div className="flex justify-center">
<div className="relative">
<div className="w-24 h-24 rounded-full bg-white/20 backdrop-blur flex items-center justify-center overflow-hidden border-2 border-white/30">
{avatar ? (
<img src={avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span className="text-3xl text-white/50">👤</span>
)}
</div>
<label className="absolute bottom-0 right-0 w-8 h-8 bg-white/20 backdrop-blur rounded-full flex items-center justify-center cursor-pointer hover:bg-white/30 transition-colors border border-white/30">
<span className="text-sm">📷</span>
<input type="file" accept="image/*" onChange={handleAvatarUpload} className="hidden" />
</label>
</div>
</div>
)}
{/* First Name (Signup only) */}
{mode === 'signup' && (
<div>
<label className="block text-sm font-medium text-white/80 mb-2">First Name</label>
<input
type="text"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className={classNames(
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
'border border-white/20 text-white placeholder-white/40',
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
'transition-all',
errors.firstName && 'border-red-400',
)}
placeholder="Enter your first name"
required
/>
{errors.firstName && <p className="mt-1 text-sm text-red-300">{errors.firstName}</p>}
</div>
)}
{/* Username */}
<div>
<label className="block text-sm font-medium text-white/80 mb-2">Username</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleInputChange}
className={classNames(
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
'border border-white/20 text-white placeholder-white/40',
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
'transition-all',
errors.username && 'border-red-400',
)}
placeholder="Enter your username"
required
/>
{errors.username && <p className="mt-1 text-sm text-red-300">{errors.username}</p>}
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-white/80 mb-2">Password</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
className={classNames(
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
'border border-white/20 text-white placeholder-white/40',
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
'transition-all',
errors.password && 'border-red-400',
)}
placeholder="Enter your password"
required
/>
{errors.password && <p className="mt-1 text-sm text-red-300">{errors.password}</p>}
{mode === 'signup' && formData.password && (
<div className="mt-2 space-y-1">
<div className="flex items-center gap-2">
<div
className={classNames(
'w-2 h-2 rounded-full',
formData.password.length >= 8 ? 'bg-green-400' : 'bg-white/30',
)}
/>
<span className="text-xs text-white/60">At least 8 characters</span>
</div>
<div className="flex items-center gap-2">
<div
className={classNames(
'w-2 h-2 rounded-full',
/[A-Z]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
)}
/>
<span className="text-xs text-white/60">One uppercase letter</span>
</div>
<div className="flex items-center gap-2">
<div
className={classNames(
'w-2 h-2 rounded-full',
/[a-z]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
)}
/>
<span className="text-xs text-white/60">One lowercase letter</span>
</div>
<div className="flex items-center gap-2">
<div
className={classNames(
'w-2 h-2 rounded-full',
/[0-9]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
)}
/>
<span className="text-xs text-white/60">One number</span>
</div>
</div>
)}
</div>
{/* Confirm Password (Signup only) */}
{mode === 'signup' && (
<div>
<label className="block text-sm font-medium text-white/80 mb-2">Confirm Password</label>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
className={classNames(
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
'border border-white/20 text-white placeholder-white/40',
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
'transition-all',
errors.confirmPassword && 'border-red-400',
)}
placeholder="Confirm your password"
required
/>
{errors.confirmPassword && <p className="mt-1 text-sm text-red-300">{errors.confirmPassword}</p>}
</div>
)}
{/* Remember Me (Login only) */}
{mode === 'login' && (
<div className="flex items-center">
<input
type="checkbox"
name="rememberMe"
id="rememberMe"
checked={formData.rememberMe}
onChange={handleInputChange}
className="w-4 h-4 rounded bg-white/10 border-white/20 text-blue-500 focus:ring-white/50"
/>
<label htmlFor="rememberMe" className="ml-2 text-sm text-white/70">
Remember me for 7 days
</label>
</div>
)}
{/* Error Message */}
{errors.general && (
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/30">
<p className="text-sm text-red-200">{errors.general}</p>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className={classNames(
'w-full py-3 rounded-lg font-semibold transition-all',
'bg-gradient-to-r from-blue-500 to-purple-600 text-white',
'hover:from-blue-600 hover:to-purple-700',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center justify-center gap-2',
'shadow-lg hover:shadow-xl',
)}
>
{loading ? (
<>
<span className="i-svg-spinners:3-dots-scale w-5 h-5" />
{mode === 'login' ? 'Signing in...' : 'Creating account...'}
</>
) : mode === 'login' ? (
'Sign In'
) : (
'Create Account'
)}
</button>
</motion.form>
</AnimatePresence>
{/* Developer Credit */}
<div className="mt-8 pt-6 border-t border-white/10">
<p className="text-center text-xs text-white/40">
Developed by <span className="text-white/60 font-medium">Keoma Wright</span>
</p>
</div>
{/* Continue as Guest */}
<div className="pb-6">
<button
onClick={() => navigate('/')}
className="w-full py-2 text-center text-sm text-white/60 hover:text-white transition-colors"
>
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Continue as Guest
</span>
</button>
</div>
</div>
</div>
</motion.div>
</div>
);
}