Revert "fix: resolve chat conversation hanging and stream interruption issues (#1971)"
This reverts commit e68593f22d.
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}, []);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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'> {
|
||||
|
||||
Reference in New Issue
Block a user