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'> {
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
/**
|
||||
* Stream Recovery Module
|
||||
* Handles stream failures and provides automatic recovery mechanisms
|
||||
* Fixes chat conversation hanging issues
|
||||
* Author: Keoma Wright
|
||||
*/
|
||||
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('stream-recovery');
|
||||
|
||||
export interface StreamRecoveryOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
timeout?: number;
|
||||
onRetry?: (attempt: number) => void;
|
||||
onTimeout?: () => void;
|
||||
onError?: (error: any) => void;
|
||||
}
|
||||
|
||||
export class StreamRecoveryManager {
|
||||
private _retryCount = 0;
|
||||
private _timeoutHandle: NodeJS.Timeout | null = null;
|
||||
private _lastActivity: number = Date.now();
|
||||
private _isActive = true;
|
||||
|
||||
constructor(private _options: StreamRecoveryOptions = {}) {
|
||||
this._options = {
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
timeout: 30000, // 30 seconds default timeout
|
||||
..._options,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring the stream for inactivity
|
||||
*/
|
||||
startMonitoring() {
|
||||
this._resetTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the timeout when activity is detected
|
||||
*/
|
||||
recordActivity() {
|
||||
this._lastActivity = Date.now();
|
||||
this._resetTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the timeout timer
|
||||
*/
|
||||
private _resetTimeout() {
|
||||
if (this._timeoutHandle) {
|
||||
clearTimeout(this._timeoutHandle);
|
||||
}
|
||||
|
||||
if (!this._isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._timeoutHandle = setTimeout(() => {
|
||||
const inactiveTime = Date.now() - this._lastActivity;
|
||||
logger.warn(`Stream timeout detected after ${inactiveTime}ms of inactivity`);
|
||||
|
||||
if (this._options.onTimeout) {
|
||||
this._options.onTimeout();
|
||||
}
|
||||
|
||||
this._handleTimeout();
|
||||
}, this._options.timeout!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stream timeout
|
||||
*/
|
||||
private _handleTimeout() {
|
||||
logger.error('Stream timeout - attempting recovery');
|
||||
|
||||
// Signal that recovery is needed
|
||||
this.attemptRecovery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover from a stream failure
|
||||
*/
|
||||
async attemptRecovery(): Promise<boolean> {
|
||||
if (this._retryCount >= this._options.maxRetries!) {
|
||||
logger.error(`Max retries (${this._options.maxRetries}) reached - cannot recover`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this._retryCount++;
|
||||
logger.info(`Attempting recovery (attempt ${this._retryCount}/${this._options.maxRetries})`);
|
||||
|
||||
if (this._options.onRetry) {
|
||||
this._options.onRetry(this._retryCount);
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await new Promise((resolve) => setTimeout(resolve, this._options.retryDelay! * this._retryCount));
|
||||
|
||||
// Reset activity tracking
|
||||
this.recordActivity();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stream errors with recovery
|
||||
*/
|
||||
async handleError(error: any): Promise<boolean> {
|
||||
logger.error('Stream error detected:', error);
|
||||
|
||||
if (this._options.onError) {
|
||||
this._options.onError(error);
|
||||
}
|
||||
|
||||
// Check if error is recoverable
|
||||
if (this._isRecoverableError(error)) {
|
||||
return await this.attemptRecovery();
|
||||
}
|
||||
|
||||
logger.error('Non-recoverable error - cannot continue');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is recoverable
|
||||
*/
|
||||
private _isRecoverableError(error: any): boolean {
|
||||
const errorMessage = error?.message || error?.toString() || '';
|
||||
|
||||
// List of recoverable error patterns
|
||||
const recoverablePatterns = [
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'ENOTFOUND',
|
||||
'socket hang up',
|
||||
'network',
|
||||
'timeout',
|
||||
'abort',
|
||||
'EPIPE',
|
||||
'502',
|
||||
'503',
|
||||
'504',
|
||||
'rate limit',
|
||||
];
|
||||
|
||||
return recoverablePatterns.some((pattern) => errorMessage.toLowerCase().includes(pattern.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring and cleanup
|
||||
*/
|
||||
stop() {
|
||||
this._isActive = false;
|
||||
|
||||
if (this._timeoutHandle) {
|
||||
clearTimeout(this._timeoutHandle);
|
||||
this._timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the recovery manager
|
||||
*/
|
||||
reset() {
|
||||
this._retryCount = 0;
|
||||
this._lastActivity = Date.now();
|
||||
this._isActive = true;
|
||||
this._resetTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wrapped stream with recovery capabilities
|
||||
*/
|
||||
export function createRecoverableStream<T>(
|
||||
streamFactory: () => Promise<ReadableStream<T>>,
|
||||
options?: StreamRecoveryOptions,
|
||||
): ReadableStream<T> {
|
||||
const recovery = new StreamRecoveryManager(options);
|
||||
let currentStream: ReadableStream<T> | null = null;
|
||||
let reader: ReadableStreamDefaultReader<T> | null = null;
|
||||
|
||||
return new ReadableStream<T>({
|
||||
async start(controller) {
|
||||
recovery.startMonitoring();
|
||||
|
||||
try {
|
||||
currentStream = await streamFactory();
|
||||
reader = currentStream.getReader();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create initial stream:', error);
|
||||
|
||||
const canRecover = await recovery.handleError(error);
|
||||
|
||||
if (canRecover) {
|
||||
// Retry creating the stream
|
||||
currentStream = await streamFactory();
|
||||
reader = currentStream.getReader();
|
||||
} else {
|
||||
controller.error(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async pull(controller) {
|
||||
if (!reader) {
|
||||
controller.error(new Error('No reader available'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
controller.close();
|
||||
recovery.stop();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Record activity to reset timeout
|
||||
recovery.recordActivity();
|
||||
controller.enqueue(value);
|
||||
} catch (error) {
|
||||
logger.error('Error reading from stream:', error);
|
||||
|
||||
const canRecover = await recovery.handleError(error);
|
||||
|
||||
if (canRecover) {
|
||||
// Try to recreate the stream
|
||||
try {
|
||||
if (reader) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
currentStream = await streamFactory();
|
||||
reader = currentStream.getReader();
|
||||
|
||||
// Continue reading
|
||||
await this.pull!(controller);
|
||||
} catch (retryError) {
|
||||
logger.error('Recovery failed:', retryError);
|
||||
controller.error(retryError);
|
||||
recovery.stop();
|
||||
}
|
||||
} else {
|
||||
controller.error(error);
|
||||
recovery.stop();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
cancel() {
|
||||
recovery.stop();
|
||||
|
||||
if (reader) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -11,65 +11,6 @@ import { createFilesContext, extractPropertiesFromMessage } from './utils';
|
||||
import { discussPrompt } from '~/lib/common/prompts/discuss-prompt';
|
||||
import type { DesignScheme } from '~/types/design-scheme';
|
||||
|
||||
function getSmartAISystemPrompt(basePrompt: string): string {
|
||||
const smartAIEnhancement = `
|
||||
## SmartAI Mode - Enhanced Conversational Coding Assistant
|
||||
|
||||
You are operating in SmartAI mode, a premium Bolt.gives feature that provides detailed, educational feedback throughout the coding process.
|
||||
|
||||
### Your Communication Style:
|
||||
- Be conversational and friendly, as if pair programming with a colleague
|
||||
- Explain your thought process clearly and educationally
|
||||
- Use natural language, not technical jargon unless necessary
|
||||
- Keep responses visible and engaging
|
||||
|
||||
### What to Communicate:
|
||||
|
||||
**When Starting Tasks:**
|
||||
✨ "I see you want [task description]. Let me [approach explanation]..."
|
||||
✨ Explain your understanding and planned approach
|
||||
✨ Share why you're choosing specific solutions
|
||||
|
||||
**During Implementation:**
|
||||
📝 "Now I'm creating/updating [file] to [purpose]..."
|
||||
📝 Explain what each code section does
|
||||
📝 Share the patterns and best practices you're using
|
||||
📝 Discuss any trade-offs or alternatives considered
|
||||
|
||||
**When Problem-Solving:**
|
||||
🔍 "I noticed [issue]. This is likely because [reasoning]..."
|
||||
🔍 Share your debugging thought process
|
||||
🔍 Explain how you're identifying and fixing issues
|
||||
🔍 Describe why your solution will work
|
||||
|
||||
**After Completing Work:**
|
||||
✅ "I've successfully [what was done]. The key changes include..."
|
||||
✅ Summarize what was accomplished
|
||||
✅ Highlight important decisions made
|
||||
✅ Suggest potential improvements or next steps
|
||||
|
||||
### Example Responses:
|
||||
|
||||
Instead of silence:
|
||||
"I understand you need a contact form. Let me create a modern, accessible form with proper validation. I'll start by setting up the form structure with semantic HTML..."
|
||||
|
||||
While coding:
|
||||
"I'm now adding email validation to ensure users enter valid email addresses. I'll use a regex pattern that covers most common email formats while keeping it user-friendly..."
|
||||
|
||||
When debugging:
|
||||
"I see the button isn't aligning properly with the other elements. This looks like a flexbox issue. Let me adjust the container's display properties to fix the alignment..."
|
||||
|
||||
### Remember:
|
||||
- Users chose SmartAI to learn from your process
|
||||
- Make every action visible and understandable
|
||||
- Be their coding companion, not just a silent worker
|
||||
- Keep the conversation flowing naturally
|
||||
|
||||
${basePrompt}`;
|
||||
|
||||
return smartAIEnhancement;
|
||||
}
|
||||
|
||||
export type Messages = Message[];
|
||||
|
||||
export interface StreamingOptions extends Omit<Parameters<typeof _streamText>[0], 'model'> {
|
||||
@@ -141,19 +82,13 @@ export async function streamText(props: {
|
||||
} = props;
|
||||
let currentModel = DEFAULT_MODEL;
|
||||
let currentProvider = DEFAULT_PROVIDER.name;
|
||||
let smartAIEnabled = false;
|
||||
let processedMessages = messages.map((message) => {
|
||||
const newMessage = { ...message };
|
||||
|
||||
if (message.role === 'user') {
|
||||
const { model, provider, content, smartAI } = extractPropertiesFromMessage(message);
|
||||
const { model, provider, content } = extractPropertiesFromMessage(message);
|
||||
currentModel = model;
|
||||
currentProvider = provider;
|
||||
|
||||
if (smartAI !== undefined) {
|
||||
smartAIEnabled = smartAI;
|
||||
}
|
||||
|
||||
newMessage.content = sanitizeText(content);
|
||||
} else if (message.role == 'assistant') {
|
||||
newMessage.content = sanitizeText(message.content);
|
||||
@@ -207,39 +142,13 @@ export async function streamText(props: {
|
||||
|
||||
const dynamicMaxTokens = modelDetails ? getCompletionTokenLimit(modelDetails) : Math.min(MAX_TOKENS, 16384);
|
||||
|
||||
// Additional safety cap - respect model-specific limits
|
||||
let safeMaxTokens = dynamicMaxTokens;
|
||||
|
||||
// Apply model-specific caps for Anthropic models
|
||||
if (modelDetails?.provider === 'Anthropic') {
|
||||
if (modelDetails.name.includes('claude-sonnet-4') || modelDetails.name.includes('claude-opus-4')) {
|
||||
safeMaxTokens = Math.min(dynamicMaxTokens, 64000);
|
||||
} else if (modelDetails.name.includes('claude-3-7-sonnet')) {
|
||||
safeMaxTokens = Math.min(dynamicMaxTokens, 64000);
|
||||
} else if (modelDetails.name.includes('claude-3-5-sonnet')) {
|
||||
safeMaxTokens = Math.min(dynamicMaxTokens, 8192);
|
||||
} else {
|
||||
safeMaxTokens = Math.min(dynamicMaxTokens, 4096);
|
||||
}
|
||||
} else {
|
||||
// General safety cap for other providers
|
||||
safeMaxTokens = Math.min(dynamicMaxTokens, 128000);
|
||||
}
|
||||
// Use model-specific limits directly - no artificial cap needed
|
||||
const safeMaxTokens = dynamicMaxTokens;
|
||||
|
||||
logger.info(
|
||||
`Max tokens for model ${modelDetails.name} is ${safeMaxTokens} (capped from ${dynamicMaxTokens}) based on model limits`,
|
||||
`Token limits for model ${modelDetails.name}: maxTokens=${safeMaxTokens}, maxTokenAllowed=${modelDetails.maxTokenAllowed}, maxCompletionTokens=${modelDetails.maxCompletionTokens}`,
|
||||
);
|
||||
|
||||
/*
|
||||
* Check if SmartAI is enabled for supported models
|
||||
* SmartAI is enabled if either:
|
||||
* 1. The model itself has isSmartAIEnabled flag (for models with SmartAI in name)
|
||||
* 2. The user explicitly enabled it via message flag
|
||||
*/
|
||||
const isSmartAISupported =
|
||||
modelDetails?.supportsSmartAI && (provider.name === 'Anthropic' || provider.name === 'OpenAI');
|
||||
const useSmartAI = (modelDetails?.isSmartAIEnabled || smartAIEnabled) && isSmartAISupported;
|
||||
|
||||
let systemPrompt =
|
||||
PromptLibrary.getPropmtFromLibrary(promptId || 'default', {
|
||||
cwd: WORK_DIR,
|
||||
@@ -253,11 +162,6 @@ export async function streamText(props: {
|
||||
},
|
||||
}) ?? getSystemPrompt();
|
||||
|
||||
// Enhance system prompt for SmartAI if enabled and supported
|
||||
if (useSmartAI) {
|
||||
systemPrompt = getSmartAISystemPrompt(systemPrompt);
|
||||
}
|
||||
|
||||
if (chatMode === 'build' && contextFiles && contextOptimization) {
|
||||
const codeContext = createFilesContext(contextFiles, true);
|
||||
|
||||
@@ -317,11 +221,18 @@ export async function streamText(props: {
|
||||
|
||||
logger.info(`Sending llm call to ${provider.name} with model ${modelDetails.name}`);
|
||||
|
||||
// DEBUG: Log reasoning model detection
|
||||
// Log reasoning model detection and token parameters
|
||||
const isReasoning = isReasoningModel(modelDetails.name);
|
||||
logger.info(`DEBUG STREAM: Model "${modelDetails.name}" detected as reasoning model: ${isReasoning}`);
|
||||
logger.info(
|
||||
`Model "${modelDetails.name}" is reasoning model: ${isReasoning}, using ${isReasoning ? 'maxCompletionTokens' : 'maxTokens'}: ${safeMaxTokens}`,
|
||||
);
|
||||
|
||||
// console.log(systemPrompt, processedMessages);
|
||||
// Validate token limits before API call
|
||||
if (safeMaxTokens > (modelDetails.maxTokenAllowed || 128000)) {
|
||||
logger.warn(
|
||||
`Token limit warning: requesting ${safeMaxTokens} tokens but model supports max ${modelDetails.maxTokenAllowed || 128000}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Use maxCompletionTokens for reasoning models (o1, GPT-5), maxTokens for traditional models
|
||||
const tokenParams = isReasoning ? { maxCompletionTokens: safeMaxTokens } : { maxTokens: safeMaxTokens };
|
||||
|
||||
@@ -8,7 +8,6 @@ export function extractPropertiesFromMessage(message: Omit<Message, 'id'>): {
|
||||
model: string;
|
||||
provider: string;
|
||||
content: string;
|
||||
smartAI?: boolean;
|
||||
} {
|
||||
const textContent = Array.isArray(message.content)
|
||||
? message.content.find((item) => item.type === 'text')?.text || ''
|
||||
@@ -17,10 +16,6 @@ export function extractPropertiesFromMessage(message: Omit<Message, 'id'>): {
|
||||
const modelMatch = textContent.match(MODEL_REGEX);
|
||||
const providerMatch = textContent.match(PROVIDER_REGEX);
|
||||
|
||||
// Check for SmartAI toggle in the message
|
||||
const smartAIMatch = textContent.match(/\[SmartAI:(true|false)\]/);
|
||||
const smartAI = smartAIMatch ? smartAIMatch[1] === 'true' : undefined;
|
||||
|
||||
/*
|
||||
* Extract model
|
||||
* const modelMatch = message.content.match(MODEL_REGEX);
|
||||
@@ -38,21 +33,15 @@ export function extractPropertiesFromMessage(message: Omit<Message, 'id'>): {
|
||||
if (item.type === 'text') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: item.text
|
||||
?.replace(MODEL_REGEX, '')
|
||||
.replace(PROVIDER_REGEX, '')
|
||||
.replace(/\[SmartAI:(true|false)\]/g, ''),
|
||||
text: item.text?.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, ''),
|
||||
};
|
||||
}
|
||||
|
||||
return item; // Preserve image_url and other types as is
|
||||
})
|
||||
: textContent
|
||||
.replace(MODEL_REGEX, '')
|
||||
.replace(PROVIDER_REGEX, '')
|
||||
.replace(/\[SmartAI:(true|false)\]/g, '');
|
||||
: textContent.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
||||
|
||||
return { model, provider, content: cleanedContent, smartAI };
|
||||
return { model, provider, content: cleanedContent };
|
||||
}
|
||||
|
||||
export function simplifyBoltActions(input: string): string {
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
/**
|
||||
* Netlify Configuration Helper
|
||||
* Contributed by Keoma Wright
|
||||
*
|
||||
* This module provides automatic configuration generation for Netlify deployments
|
||||
*/
|
||||
|
||||
export interface NetlifyConfig {
|
||||
build: {
|
||||
command?: string;
|
||||
publish: string;
|
||||
functions?: string;
|
||||
environment?: Record<string, string>;
|
||||
};
|
||||
redirects?: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
status?: number;
|
||||
force?: boolean;
|
||||
}>;
|
||||
headers?: Array<{
|
||||
for: string;
|
||||
values: Record<string, string>;
|
||||
}>;
|
||||
functions?: {
|
||||
[key: string]: {
|
||||
included_files?: string[];
|
||||
external_node_modules?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface FrameworkConfig {
|
||||
name: string;
|
||||
buildCommand: string;
|
||||
outputDirectory: string;
|
||||
nodeVersion: string;
|
||||
installCommand?: string;
|
||||
envVars?: Record<string, string>;
|
||||
}
|
||||
|
||||
const FRAMEWORK_CONFIGS: Record<string, FrameworkConfig> = {
|
||||
react: {
|
||||
name: 'React',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'build',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
'react-vite': {
|
||||
name: 'React (Vite)',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'dist',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
vue: {
|
||||
name: 'Vue',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'dist',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
angular: {
|
||||
name: 'Angular',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'dist',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
svelte: {
|
||||
name: 'Svelte',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'public',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
'svelte-kit': {
|
||||
name: 'SvelteKit',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: '.svelte-kit',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
next: {
|
||||
name: 'Next.js',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: '.next',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
envVars: {
|
||||
NEXT_TELEMETRY_DISABLED: '1',
|
||||
},
|
||||
},
|
||||
nuxt: {
|
||||
name: 'Nuxt',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: '.output/public',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
gatsby: {
|
||||
name: 'Gatsby',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'public',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
remix: {
|
||||
name: 'Remix',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'public',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
astro: {
|
||||
name: 'Astro',
|
||||
buildCommand: 'npm run build',
|
||||
outputDirectory: 'dist',
|
||||
nodeVersion: '18',
|
||||
installCommand: 'npm install',
|
||||
},
|
||||
static: {
|
||||
name: 'Static Site',
|
||||
buildCommand: '',
|
||||
outputDirectory: '.',
|
||||
nodeVersion: '18',
|
||||
},
|
||||
};
|
||||
|
||||
export function detectFramework(packageJson: any): string {
|
||||
const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
|
||||
|
||||
// Check for specific frameworks
|
||||
if (deps.next) {
|
||||
return 'next';
|
||||
}
|
||||
|
||||
if (deps.nuxt || deps.nuxt3) {
|
||||
return 'nuxt';
|
||||
}
|
||||
|
||||
if (deps.gatsby) {
|
||||
return 'gatsby';
|
||||
}
|
||||
|
||||
if (deps['@remix-run/react']) {
|
||||
return 'remix';
|
||||
}
|
||||
|
||||
if (deps.astro) {
|
||||
return 'astro';
|
||||
}
|
||||
|
||||
if (deps['@angular/core']) {
|
||||
return 'angular';
|
||||
}
|
||||
|
||||
if (deps['@sveltejs/kit']) {
|
||||
return 'svelte-kit';
|
||||
}
|
||||
|
||||
if (deps.svelte) {
|
||||
return 'svelte';
|
||||
}
|
||||
|
||||
if (deps.vue) {
|
||||
return 'vue';
|
||||
}
|
||||
|
||||
if (deps.react) {
|
||||
if (deps.vite) {
|
||||
return 'react-vite';
|
||||
}
|
||||
|
||||
return 'react';
|
||||
}
|
||||
|
||||
return 'static';
|
||||
}
|
||||
|
||||
export function generateNetlifyConfig(framework: string, customConfig?: Partial<NetlifyConfig>): NetlifyConfig {
|
||||
const frameworkConfig = FRAMEWORK_CONFIGS[framework] || FRAMEWORK_CONFIGS.static;
|
||||
|
||||
const config: NetlifyConfig = {
|
||||
build: {
|
||||
command: frameworkConfig.buildCommand,
|
||||
publish: frameworkConfig.outputDirectory,
|
||||
environment: {
|
||||
NODE_VERSION: frameworkConfig.nodeVersion,
|
||||
...frameworkConfig.envVars,
|
||||
...customConfig?.build?.environment,
|
||||
},
|
||||
},
|
||||
redirects: [],
|
||||
headers: [
|
||||
{
|
||||
for: '/*',
|
||||
values: {
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Add SPA redirect for client-side routing frameworks
|
||||
if (['react', 'react-vite', 'vue', 'angular', 'svelte'].includes(framework)) {
|
||||
config.redirects!.push({
|
||||
from: '/*',
|
||||
to: '/index.html',
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
// Add custom headers for static assets
|
||||
config.headers!.push({
|
||||
for: '/assets/*',
|
||||
values: {
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
|
||||
// Merge with custom config
|
||||
if (customConfig) {
|
||||
if (customConfig.redirects) {
|
||||
config.redirects!.push(...customConfig.redirects);
|
||||
}
|
||||
|
||||
if (customConfig.headers) {
|
||||
config.headers!.push(...customConfig.headers);
|
||||
}
|
||||
|
||||
if (customConfig.functions) {
|
||||
config.functions = customConfig.functions;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function generateNetlifyToml(config: NetlifyConfig): string {
|
||||
let toml = '';
|
||||
|
||||
// Build configuration
|
||||
toml += '[build]\n';
|
||||
|
||||
if (config.build.command) {
|
||||
toml += ` command = "${config.build.command}"\n`;
|
||||
}
|
||||
|
||||
toml += ` publish = "${config.build.publish}"\n`;
|
||||
|
||||
if (config.build.functions) {
|
||||
toml += ` functions = "${config.build.functions}"\n`;
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
if (config.build.environment && Object.keys(config.build.environment).length > 0) {
|
||||
toml += '\n[build.environment]\n';
|
||||
|
||||
for (const [key, value] of Object.entries(config.build.environment)) {
|
||||
toml += ` ${key} = "${value}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Redirects
|
||||
if (config.redirects && config.redirects.length > 0) {
|
||||
for (const redirect of config.redirects) {
|
||||
toml += '\n[[redirects]]\n';
|
||||
toml += ` from = "${redirect.from}"\n`;
|
||||
toml += ` to = "${redirect.to}"\n`;
|
||||
|
||||
if (redirect.status) {
|
||||
toml += ` status = ${redirect.status}\n`;
|
||||
}
|
||||
|
||||
if (redirect.force) {
|
||||
toml += ` force = ${redirect.force}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Headers
|
||||
if (config.headers && config.headers.length > 0) {
|
||||
for (const header of config.headers) {
|
||||
toml += '\n[[headers]]\n';
|
||||
toml += ` for = "${header.for}"\n`;
|
||||
|
||||
if (Object.keys(header.values).length > 0) {
|
||||
toml += ' [headers.values]\n';
|
||||
|
||||
for (const [key, value] of Object.entries(header.values)) {
|
||||
toml += ` "${key}" = "${value}"\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Functions configuration
|
||||
if (config.functions) {
|
||||
for (const [funcName, funcConfig] of Object.entries(config.functions)) {
|
||||
toml += `\n[functions."${funcName}"]\n`;
|
||||
|
||||
if (funcConfig.included_files) {
|
||||
toml += ` included_files = ${JSON.stringify(funcConfig.included_files)}\n`;
|
||||
}
|
||||
|
||||
if (funcConfig.external_node_modules) {
|
||||
toml += ` external_node_modules = ${JSON.stringify(funcConfig.external_node_modules)}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toml;
|
||||
}
|
||||
|
||||
export function validateDeploymentFiles(files: Record<string, string>): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for index.html
|
||||
const hasIndex = Object.keys(files).some(
|
||||
(path) => path === '/index.html' || path === 'index.html' || path.endsWith('/index.html'),
|
||||
);
|
||||
|
||||
if (!hasIndex) {
|
||||
warnings.push('No index.html file found. Make sure your build output includes an entry point.');
|
||||
}
|
||||
|
||||
// Check file sizes
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
const WARN_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
const size = new Blob([content]).size;
|
||||
|
||||
if (size > MAX_FILE_SIZE) {
|
||||
errors.push(`File ${path} exceeds maximum size of 100MB`);
|
||||
} else if (size > WARN_FILE_SIZE) {
|
||||
warnings.push(`File ${path} is large (${Math.round(size / 1024 / 1024)}MB)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check total deployment size
|
||||
const totalSize = Object.values(files).reduce((sum, content) => sum + new Blob([content]).size, 0);
|
||||
|
||||
const MAX_TOTAL_SIZE = 500 * 1024 * 1024; // 500MB
|
||||
|
||||
if (totalSize > MAX_TOTAL_SIZE) {
|
||||
errors.push(`Total deployment size exceeds 500MB limit`);
|
||||
}
|
||||
|
||||
// Check for common issues
|
||||
if (Object.keys(files).some((path) => path.includes('node_modules'))) {
|
||||
warnings.push('Deployment includes node_modules - these should typically be excluded');
|
||||
}
|
||||
|
||||
if (Object.keys(files).some((path) => path.includes('.env'))) {
|
||||
errors.push('Deployment includes .env file - remove sensitive configuration files');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -20,18 +20,6 @@ export default class AmazonBedrockProvider extends BaseProvider {
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{
|
||||
name: 'anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
label: 'Claude Sonnet 4 (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 200000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic.claude-opus-4-1-20250805-v1:0',
|
||||
label: 'Claude Opus 4.1 (Bedrock)',
|
||||
provider: 'AmazonBedrock',
|
||||
maxTokenAllowed: 200000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
label: 'Claude 3.5 Sonnet v2 (Bedrock)',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { BaseProvider } from '~/lib/modules/llm/base-provider';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
import type { LanguageModelV1 } from 'ai';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
|
||||
export class AnthropicProvider extends BaseProvider {
|
||||
export default class AnthropicProvider extends BaseProvider {
|
||||
name = 'Anthropic';
|
||||
getApiKeyLink = 'https://console.anthropic.com/settings/keys';
|
||||
|
||||
@@ -13,50 +13,6 @@ export class AnthropicProvider extends BaseProvider {
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
/*
|
||||
* Claude Opus 4.1: Most powerful model for coding and reasoning
|
||||
* Released August 5, 2025
|
||||
*/
|
||||
{
|
||||
name: 'claude-opus-4-1-20250805',
|
||||
label: 'Claude Opus 4.1',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'claude-opus-4-1-20250805-smartai',
|
||||
label: 'Claude Opus 4.1 (SmartAI)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
|
||||
/*
|
||||
* Claude Sonnet 4: Hybrid instant/extended response model
|
||||
* Released May 14, 2025
|
||||
*/
|
||||
{
|
||||
name: 'claude-sonnet-4-20250514',
|
||||
label: 'Claude Sonnet 4',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'claude-sonnet-4-20250514-smartai',
|
||||
label: 'Claude Sonnet 4 (SmartAI)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
|
||||
/*
|
||||
* Essential fallback models - only the most stable/reliable ones
|
||||
* Claude 3.5 Sonnet: 200k context, excellent for complex reasoning and coding
|
||||
@@ -66,17 +22,7 @@ export class AnthropicProvider extends BaseProvider {
|
||||
label: 'Claude 3.5 Sonnet',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 8192,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'claude-3-5-sonnet-20241022-smartai',
|
||||
label: 'Claude 3.5 Sonnet (SmartAI)',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 8192,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
maxCompletionTokens: 128000,
|
||||
},
|
||||
|
||||
// Claude 3 Haiku: 200k context, fastest and most cost-effective
|
||||
@@ -85,17 +31,16 @@ export class AnthropicProvider extends BaseProvider {
|
||||
label: 'Claude 3 Haiku',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
maxCompletionTokens: 128000,
|
||||
},
|
||||
|
||||
// Claude Opus 4: 200k context, 32k output limit (latest flagship model)
|
||||
{
|
||||
name: 'claude-3-haiku-20240307-smartai',
|
||||
label: 'Claude 3 Haiku (SmartAI)',
|
||||
name: 'claude-opus-4-20250514',
|
||||
label: 'Claude 4 Opus',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 200000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
maxCompletionTokens: 32000,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -119,8 +64,7 @@ export class AnthropicProvider extends BaseProvider {
|
||||
const response = await fetch(`https://api.anthropic.com/v1/models`, {
|
||||
headers: {
|
||||
'x-api-key': `${apiKey}`,
|
||||
['anthropic-version']: '2023-06-01',
|
||||
['Content-Type']: 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -146,21 +90,15 @@ export class AnthropicProvider extends BaseProvider {
|
||||
contextWindow = 200000; // Claude 3 Sonnet has 200k context
|
||||
}
|
||||
|
||||
// Determine max completion tokens based on model
|
||||
let maxCompletionTokens = 4096; // default fallback
|
||||
// Determine completion token limits based on specific model
|
||||
let maxCompletionTokens = 128000; // default for older Claude 3 models
|
||||
|
||||
if (m.id?.includes('claude-sonnet-4') || m.id?.includes('claude-opus-4')) {
|
||||
maxCompletionTokens = 64000;
|
||||
} else if (m.id?.includes('claude-3-7-sonnet')) {
|
||||
maxCompletionTokens = 64000;
|
||||
} else if (m.id?.includes('claude-3-5-sonnet')) {
|
||||
maxCompletionTokens = 8192;
|
||||
} else if (m.id?.includes('claude-3-haiku')) {
|
||||
maxCompletionTokens = 4096;
|
||||
} else if (m.id?.includes('claude-3-opus')) {
|
||||
maxCompletionTokens = 4096;
|
||||
} else if (m.id?.includes('claude-3-sonnet')) {
|
||||
maxCompletionTokens = 4096;
|
||||
if (m.id?.includes('claude-opus-4')) {
|
||||
maxCompletionTokens = 32000; // Claude 4 Opus: 32K output limit
|
||||
} else if (m.id?.includes('claude-sonnet-4')) {
|
||||
maxCompletionTokens = 64000; // Claude 4 Sonnet: 64K output limit
|
||||
} else if (m.id?.includes('claude-4')) {
|
||||
maxCompletionTokens = 32000; // Other Claude 4 models: conservative 32K limit
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -169,7 +107,6 @@ export class AnthropicProvider extends BaseProvider {
|
||||
provider: this.name,
|
||||
maxTokenAllowed: contextWindow,
|
||||
maxCompletionTokens,
|
||||
supportsSmartAI: true, // All Anthropic models support SmartAI
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -180,27 +117,19 @@ export class AnthropicProvider extends BaseProvider {
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}) => LanguageModelV1 = (options) => {
|
||||
const { model, serverEnv, apiKeys, providerSettings } = options;
|
||||
const { apiKey, baseUrl } = this.getProviderBaseUrlAndKey({
|
||||
const { apiKeys, providerSettings, serverEnv, model } = options;
|
||||
const { apiKey } = this.getProviderBaseUrlAndKey({
|
||||
apiKeys,
|
||||
providerSettings: providerSettings?.[this.name],
|
||||
providerSettings,
|
||||
serverEnv: serverEnv as any,
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'ANTHROPIC_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw `Missing API key for ${this.name} provider`;
|
||||
}
|
||||
|
||||
const anthropic = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: baseUrl || 'https://api.anthropic.com/v1',
|
||||
headers: { 'anthropic-beta': 'output-128k-2025-02-19' },
|
||||
});
|
||||
|
||||
// Handle SmartAI variant by using the base model name
|
||||
const actualModel = model.replace('-smartai', '');
|
||||
|
||||
return anthropic(actualModel);
|
||||
return anthropic(model);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,18 +31,6 @@ export default class OpenRouterProvider extends BaseProvider {
|
||||
* Essential fallback models - only the most stable/reliable ones
|
||||
* Claude 3.5 Sonnet via OpenRouter: 200k context
|
||||
*/
|
||||
{
|
||||
name: 'anthropic/claude-sonnet-4-20250514',
|
||||
label: 'Anthropic: Claude Sonnet 4 (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 200000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic/claude-opus-4-1-20250805',
|
||||
label: 'Anthropic: Claude Opus 4.1 (OpenRouter)',
|
||||
provider: 'OpenRouter',
|
||||
maxTokenAllowed: 200000,
|
||||
},
|
||||
{
|
||||
name: 'anthropic/claude-3.5-sonnet',
|
||||
label: 'Claude 3.5 Sonnet',
|
||||
|
||||
@@ -17,23 +17,7 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
* Essential fallback models - only the most stable/reliable ones
|
||||
* GPT-4o: 128k context, 4k standard output (64k with long output mode)
|
||||
*/
|
||||
{
|
||||
name: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'gpt-4o-smartai',
|
||||
label: 'GPT-4o (SmartAI)',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
{ name: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI', maxTokenAllowed: 128000, maxCompletionTokens: 4096 },
|
||||
|
||||
// GPT-4o Mini: 128k context, cost-effective alternative
|
||||
{
|
||||
@@ -42,16 +26,6 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'gpt-4o-mini-smartai',
|
||||
label: 'GPT-4o Mini (SmartAI)',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
|
||||
// GPT-3.5-turbo: 16k context, fast and cost-effective
|
||||
@@ -61,16 +35,6 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 16000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'gpt-3.5-turbo-smartai',
|
||||
label: 'GPT-3.5 Turbo (SmartAI)',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 16000,
|
||||
maxCompletionTokens: 4096,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
|
||||
// o1-preview: 128k context, 32k output limit (reasoning model)
|
||||
@@ -80,36 +44,10 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 32000,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'o1-preview-smartai',
|
||||
label: 'o1-preview (SmartAI)',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 32000,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
|
||||
// o1-mini: 128k context, 65k output limit (reasoning model)
|
||||
{
|
||||
name: 'o1-mini',
|
||||
label: 'o1-mini',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 65000,
|
||||
supportsSmartAI: false, // Base model without SmartAI
|
||||
},
|
||||
{
|
||||
name: 'o1-mini-smartai',
|
||||
label: 'o1-mini (SmartAI)',
|
||||
provider: 'OpenAI',
|
||||
maxTokenAllowed: 128000,
|
||||
maxCompletionTokens: 65000,
|
||||
supportsSmartAI: true,
|
||||
isSmartAIEnabled: true,
|
||||
},
|
||||
{ name: 'o1-mini', label: 'o1-mini', provider: 'OpenAI', maxTokenAllowed: 128000, maxCompletionTokens: 65000 },
|
||||
];
|
||||
|
||||
async getDynamicModels(
|
||||
@@ -187,7 +125,6 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
provider: this.name,
|
||||
maxTokenAllowed: Math.min(contextWindow, 128000), // Cap at 128k for safety
|
||||
maxCompletionTokens,
|
||||
supportsSmartAI: true, // All OpenAI models support SmartAI
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -216,9 +153,6 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
apiKey,
|
||||
});
|
||||
|
||||
// Handle SmartAI variant by using the base model name
|
||||
const actualModel = model.replace('-smartai', '');
|
||||
|
||||
return openai(actualModel);
|
||||
return openai(model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnthropicProvider } from './providers/anthropic';
|
||||
import AnthropicProvider from './providers/anthropic';
|
||||
import CohereProvider from './providers/cohere';
|
||||
import DeepseekProvider from './providers/deepseek';
|
||||
import GoogleProvider from './providers/google';
|
||||
|
||||
@@ -11,12 +11,6 @@ export interface ModelInfo {
|
||||
|
||||
/** Maximum completion/output tokens - how many tokens the model can generate. If not specified, falls back to provider defaults */
|
||||
maxCompletionTokens?: number;
|
||||
|
||||
/** Indicates if this model supports SmartAI enhanced feedback */
|
||||
supportsSmartAI?: boolean;
|
||||
|
||||
/** Indicates if SmartAI is currently enabled for this model variant */
|
||||
isSmartAIEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import type { ChatHistoryItem } from './useChatHistory';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
|
||||
export interface IUserChatMetadata {
|
||||
userId: string;
|
||||
gitUrl?: string;
|
||||
gitBranch?: string;
|
||||
netlifySiteId?: string;
|
||||
}
|
||||
|
||||
const logger = createScopedLogger('UserChatHistory');
|
||||
|
||||
/**
|
||||
* Open user-specific database
|
||||
*/
|
||||
export async function openUserDatabase(): Promise<IDBDatabase | undefined> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
console.error('indexedDB is not available in this environment.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const authState = authStore.get();
|
||||
|
||||
if (!authState.user?.id) {
|
||||
console.error('No authenticated user found.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use user-specific database name
|
||||
const dbName = `boltHistory_${authState.user.id}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
|
||||
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!db.objectStoreNames.contains('chats')) {
|
||||
const store = db.createObjectStore('chats', { keyPath: 'id' });
|
||||
store.createIndex('id', 'id', { unique: true });
|
||||
store.createIndex('urlId', 'urlId', { unique: true });
|
||||
store.createIndex('userId', 'userId', { unique: false });
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('snapshots')) {
|
||||
db.createObjectStore('snapshots', { keyPath: 'chatId' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('settings')) {
|
||||
db.createObjectStore('settings', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('workspaces')) {
|
||||
const workspaceStore = db.createObjectStore('workspaces', { keyPath: 'id' });
|
||||
workspaceStore.createIndex('name', 'name', { unique: false });
|
||||
workspaceStore.createIndex('createdAt', 'createdAt', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event: Event) => {
|
||||
resolve((event.target as IDBOpenDBRequest).result);
|
||||
};
|
||||
|
||||
request.onerror = (event: Event) => {
|
||||
resolve(undefined);
|
||||
logger.error((event.target as IDBOpenDBRequest).error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all chats for current user
|
||||
*/
|
||||
export async function getUserChats(db: IDBDatabase): Promise<ChatHistoryItem[]> {
|
||||
const authState = authStore.get();
|
||||
|
||||
if (!authState.user?.id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('chats', 'readonly');
|
||||
const store = transaction.objectStore('chats');
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
// Filter by userId and sort by timestamp
|
||||
const chats = (request.result as ChatHistoryItem[]).sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
);
|
||||
|
||||
resolve(chats);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user-specific settings
|
||||
*/
|
||||
export async function saveUserSetting(db: IDBDatabase, key: string, value: any): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('settings', 'readwrite');
|
||||
const store = transaction.objectStore('settings');
|
||||
|
||||
const request = store.put({ key, value, updatedAt: new Date().toISOString() });
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user-specific settings
|
||||
*/
|
||||
export async function loadUserSetting(db: IDBDatabase, key: string): Promise<any | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('settings', 'readonly');
|
||||
const store = transaction.objectStore('settings');
|
||||
const request = store.get(key);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result;
|
||||
resolve(result ? result.value : null);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace for the user
|
||||
*/
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
lastAccessed?: string;
|
||||
files?: Record<string, any>;
|
||||
}
|
||||
|
||||
export async function createWorkspace(db: IDBDatabase, workspace: Omit<Workspace, 'id'>): Promise<string> {
|
||||
const authState = authStore.get();
|
||||
|
||||
if (!authState.user?.id) {
|
||||
throw new Error('No authenticated user');
|
||||
}
|
||||
|
||||
const workspaceId = `workspace_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('workspaces', 'readwrite');
|
||||
const store = transaction.objectStore('workspaces');
|
||||
|
||||
const fullWorkspace: Workspace = {
|
||||
id: workspaceId,
|
||||
...workspace,
|
||||
};
|
||||
|
||||
const request = store.add(fullWorkspace);
|
||||
|
||||
request.onsuccess = () => resolve(workspaceId);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user workspaces
|
||||
*/
|
||||
export async function getUserWorkspaces(db: IDBDatabase): Promise<Workspace[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('workspaces', 'readonly');
|
||||
const store = transaction.objectStore('workspaces');
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const workspaces = (request.result as Workspace[]).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
resolve(workspaces);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a workspace
|
||||
*/
|
||||
export async function deleteWorkspace(db: IDBDatabase, workspaceId: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('workspaces', 'readwrite');
|
||||
const store = transaction.objectStore('workspaces');
|
||||
const request = store.delete(workspaceId);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user statistics
|
||||
*/
|
||||
export async function getUserStats(db: IDBDatabase): Promise<{
|
||||
totalChats: number;
|
||||
totalWorkspaces: number;
|
||||
lastActivity?: string;
|
||||
storageUsed?: number;
|
||||
}> {
|
||||
try {
|
||||
const [chats, workspaces] = await Promise.all([getUserChats(db), getUserWorkspaces(db)]);
|
||||
|
||||
// Calculate last activity
|
||||
let lastActivity: string | undefined;
|
||||
|
||||
const allTimestamps = [
|
||||
...chats.map((c) => c.timestamp),
|
||||
...workspaces.map((w) => w.lastAccessed || w.createdAt),
|
||||
].filter(Boolean);
|
||||
|
||||
if (allTimestamps.length > 0) {
|
||||
lastActivity = allTimestamps.sort().reverse()[0];
|
||||
}
|
||||
|
||||
return {
|
||||
totalChats: chats.length,
|
||||
totalWorkspaces: workspaces.length,
|
||||
lastActivity,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user stats:', error);
|
||||
return {
|
||||
totalChats: 0,
|
||||
totalWorkspaces: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { atom, map } from 'nanostores';
|
||||
import type { UserProfile } from '~/lib/utils/fileUserStorage';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: Omit<UserProfile, 'passwordHash'> | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
// Authentication state store
|
||||
export const authStore = map<AuthState>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: true,
|
||||
});
|
||||
|
||||
// Remember me preference
|
||||
export const rememberMeStore = atom<boolean>(false);
|
||||
|
||||
// Session timeout tracking
|
||||
let sessionTimeout: NodeJS.Timeout | null = null;
|
||||
const SESSION_TIMEOUT = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
/**
|
||||
* Initialize auth from stored token
|
||||
*/
|
||||
export async function initializeAuth(): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
authStore.setKey('loading', true);
|
||||
|
||||
try {
|
||||
const token = Cookies.get('auth_token');
|
||||
|
||||
if (token) {
|
||||
// Verify token with backend
|
||||
const response = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { user: Omit<UserProfile, 'passwordHash'> };
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: data.user,
|
||||
token,
|
||||
loading: false,
|
||||
});
|
||||
startSessionTimer();
|
||||
} else {
|
||||
// Token is invalid, clear it
|
||||
clearAuth();
|
||||
}
|
||||
} else {
|
||||
authStore.setKey('loading', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
authStore.setKey('loading', false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication state
|
||||
*/
|
||||
export function setAuthState(state: AuthState): void {
|
||||
authStore.set(state);
|
||||
|
||||
if (state.token) {
|
||||
// Store token in cookie
|
||||
const cookieOptions = rememberMeStore.get()
|
||||
? { expires: 7 } // 7 days
|
||||
: undefined; // Session cookie
|
||||
|
||||
Cookies.set('auth_token', state.token, cookieOptions);
|
||||
|
||||
// Store user preferences in localStorage
|
||||
if (state.user) {
|
||||
localStorage.setItem(`bolt_user_${state.user.id}`, JSON.stringify(state.user.preferences || {}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
*/
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
rememberMe: boolean = false,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
user?: Omit<UserProfile, 'passwordHash'>;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
rememberMeStore.set(rememberMe);
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: data.user || null,
|
||||
token: data.token || null,
|
||||
loading: false,
|
||||
});
|
||||
startSessionTimer();
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Login failed' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return { success: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signup new user
|
||||
*/
|
||||
export async function signup(
|
||||
username: string,
|
||||
password: string,
|
||||
firstName: string,
|
||||
avatar?: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password, firstName, avatar }),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
user?: Omit<UserProfile, 'passwordHash'>;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: data.user || null,
|
||||
token: data.token || null,
|
||||
loading: false,
|
||||
});
|
||||
startSessionTimer();
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Signup failed' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Signup error:', error);
|
||||
return { success: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
const state = authStore.get();
|
||||
|
||||
if (state.token) {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${state.token}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication state
|
||||
*/
|
||||
function clearAuth(): void {
|
||||
authStore.set({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
Cookies.remove('auth_token');
|
||||
stopSessionTimer();
|
||||
|
||||
// Clear user-specific localStorage
|
||||
const currentUser = authStore.get().user;
|
||||
|
||||
if (currentUser?.id) {
|
||||
// Keep preferences but clear sensitive data
|
||||
const prefs = localStorage.getItem(`bolt_user_${currentUser.id}`);
|
||||
|
||||
if (prefs) {
|
||||
try {
|
||||
const parsed = JSON.parse(prefs);
|
||||
delete parsed.deploySettings;
|
||||
delete parsed.githubSettings;
|
||||
localStorage.setItem(`bolt_user_${currentUser.id}`, JSON.stringify(parsed));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start session timer
|
||||
*/
|
||||
function startSessionTimer(): void {
|
||||
stopSessionTimer();
|
||||
|
||||
if (!rememberMeStore.get()) {
|
||||
sessionTimeout = setTimeout(() => {
|
||||
logout();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/auth';
|
||||
}
|
||||
}, SESSION_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop session timer
|
||||
*/
|
||||
function stopSessionTimer(): void {
|
||||
if (sessionTimeout) {
|
||||
clearTimeout(sessionTimeout);
|
||||
sessionTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
export async function updateProfile(
|
||||
updates: Partial<Omit<UserProfile, 'passwordHash' | 'id' | 'username'>>,
|
||||
): Promise<boolean> {
|
||||
const state = authStore.get();
|
||||
|
||||
if (!state.token || !state.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${state.token}`,
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedUser = (await response.json()) as Omit<UserProfile, 'passwordHash'>;
|
||||
authStore.setKey('user', updatedUser);
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize auth on load
|
||||
if (typeof window !== 'undefined') {
|
||||
initializeAuth();
|
||||
}
|
||||
@@ -223,13 +223,10 @@ export class WorkbenchStore {
|
||||
}
|
||||
|
||||
async saveFile(filePath: string) {
|
||||
console.log(`[WorkbenchStore] saveFile called for: ${filePath}`);
|
||||
|
||||
const documents = this.#editorStore.documents.get();
|
||||
const document = documents[filePath];
|
||||
|
||||
if (document === undefined) {
|
||||
console.warn(`[WorkbenchStore] No document found for: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,39 +236,21 @@ export class WorkbenchStore {
|
||||
* This is a more complex feature that would be implemented in a future update
|
||||
*/
|
||||
|
||||
try {
|
||||
console.log(`[WorkbenchStore] Saving to file system: ${filePath}`);
|
||||
await this.#filesStore.saveFile(filePath, document.value);
|
||||
console.log(`[WorkbenchStore] File saved successfully: ${filePath}`);
|
||||
await this.#filesStore.saveFile(filePath, document.value);
|
||||
|
||||
const newUnsavedFiles = new Set(this.unsavedFiles.get());
|
||||
const wasUnsaved = newUnsavedFiles.has(filePath);
|
||||
newUnsavedFiles.delete(filePath);
|
||||
const newUnsavedFiles = new Set(this.unsavedFiles.get());
|
||||
newUnsavedFiles.delete(filePath);
|
||||
|
||||
console.log(`[WorkbenchStore] Updating unsaved files:`, {
|
||||
filePath,
|
||||
wasUnsaved,
|
||||
previousCount: this.unsavedFiles.get().size,
|
||||
newCount: newUnsavedFiles.size,
|
||||
remainingFiles: Array.from(newUnsavedFiles),
|
||||
});
|
||||
|
||||
this.unsavedFiles.set(newUnsavedFiles);
|
||||
} catch (error) {
|
||||
console.error(`[WorkbenchStore] Failed to save file ${filePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
this.unsavedFiles.set(newUnsavedFiles);
|
||||
}
|
||||
|
||||
async saveCurrentDocument() {
|
||||
const currentDocument = this.currentDocument.get();
|
||||
|
||||
if (currentDocument === undefined) {
|
||||
console.warn('[WorkbenchStore] No current document to save');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[WorkbenchStore] Saving current document: ${currentDocument.filePath}`);
|
||||
await this.saveFile(currentDocument.filePath);
|
||||
}
|
||||
|
||||
@@ -293,14 +272,9 @@ export class WorkbenchStore {
|
||||
}
|
||||
|
||||
async saveAllFiles() {
|
||||
const filesToSave = Array.from(this.unsavedFiles.get());
|
||||
console.log(`[WorkbenchStore] saveAllFiles called for ${filesToSave.length} files:`, filesToSave);
|
||||
|
||||
for (const filePath of filesToSave) {
|
||||
for (const filePath of this.unsavedFiles.get()) {
|
||||
await this.saveFile(filePath);
|
||||
}
|
||||
|
||||
console.log('[WorkbenchStore] saveAllFiles complete. Remaining unsaved:', Array.from(this.unsavedFiles.get()));
|
||||
}
|
||||
|
||||
getFileModifcations() {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
// Use a secure secret key (in production, this should be an environment variable)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'bolt-multi-user-secret-key-2024-secure';
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
export interface JWTPayload {
|
||||
userId: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
*/
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token
|
||||
*/
|
||||
export function generateToken(payload: Omit<JWTPayload, 'exp'>): string {
|
||||
return jwt.sign(
|
||||
{
|
||||
...payload,
|
||||
exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days
|
||||
},
|
||||
JWT_SECRET,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a JWT token
|
||||
*/
|
||||
export function verifyToken(token: string): JWTPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET) as JWTPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure user ID
|
||||
*/
|
||||
export function generateUserId(): string {
|
||||
return `user_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
*/
|
||||
export function validatePassword(password: string): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('Password must be at least 8 characters long');
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one uppercase letter');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('Password must contain at least one lowercase letter');
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { generateUserId, hashPassword } from './crypto';
|
||||
|
||||
const USERS_DIR = path.join(process.cwd(), '.users');
|
||||
const USERS_INDEX_FILE = path.join(USERS_DIR, 'users.json');
|
||||
const USER_DATA_DIR = path.join(USERS_DIR, 'data');
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
passwordHash: string;
|
||||
avatar?: string;
|
||||
createdAt: string;
|
||||
lastLogin?: string;
|
||||
preferences: UserPreferences;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
theme: 'light' | 'dark';
|
||||
deploySettings: {
|
||||
netlify?: any;
|
||||
vercel?: any;
|
||||
};
|
||||
githubSettings?: any;
|
||||
workspaceConfig: any;
|
||||
}
|
||||
|
||||
export interface SecurityLog {
|
||||
timestamp: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
action: 'login' | 'logout' | 'signup' | 'delete' | 'error' | 'failed_login';
|
||||
details: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the user storage system
|
||||
*/
|
||||
export async function initializeUserStorage(): Promise<void> {
|
||||
try {
|
||||
// Create directories if they don't exist
|
||||
await fs.mkdir(USERS_DIR, { recursive: true });
|
||||
await fs.mkdir(USER_DATA_DIR, { recursive: true });
|
||||
|
||||
// Create users index if it doesn't exist
|
||||
try {
|
||||
await fs.access(USERS_INDEX_FILE);
|
||||
} catch {
|
||||
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users: [] }, null, 2));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize user storage:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users (without passwords)
|
||||
*/
|
||||
export async function getAllUsers(): Promise<Omit<UserProfile, 'passwordHash'>[]> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
return users.map(({ passwordHash, ...user }) => user);
|
||||
} catch (error) {
|
||||
console.error('Failed to get users:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by username
|
||||
*/
|
||||
export async function getUserByUsername(username: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
return users.find((u) => u.username === username) || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by ID
|
||||
*/
|
||||
export async function getUserById(id: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
return users.find((u) => u.id === id) || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
export async function createUser(
|
||||
username: string,
|
||||
password: string,
|
||||
firstName: string,
|
||||
avatar?: string,
|
||||
): Promise<UserProfile | null> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = await getUserByUsername(username);
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const newUser: UserProfile = {
|
||||
id: generateUserId(),
|
||||
username,
|
||||
firstName,
|
||||
passwordHash: await hashPassword(password),
|
||||
avatar,
|
||||
createdAt: new Date().toISOString(),
|
||||
preferences: {
|
||||
theme: 'dark',
|
||||
deploySettings: {},
|
||||
workspaceConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Load existing users
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
// Add new user
|
||||
users.push(newUser);
|
||||
|
||||
// Save updated users
|
||||
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2));
|
||||
|
||||
// Create user data directory
|
||||
const userDataDir = path.join(USER_DATA_DIR, newUser.id);
|
||||
await fs.mkdir(userDataDir, { recursive: true });
|
||||
|
||||
// Log the signup
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: newUser.id,
|
||||
username: newUser.username,
|
||||
action: 'signup',
|
||||
details: `User ${newUser.username} created successfully`,
|
||||
});
|
||||
|
||||
return newUser;
|
||||
} catch (error) {
|
||||
console.error('Failed to create user:', error);
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'error',
|
||||
details: `Failed to create user ${username}: ${error}`,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
export async function updateUser(userId: string, updates: Partial<UserProfile>): Promise<boolean> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
const userIndex = users.findIndex((u) => u.id === userId);
|
||||
|
||||
if (userIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user (excluding certain fields)
|
||||
const { id, username, passwordHash, ...safeUpdates } = updates;
|
||||
users[userIndex] = {
|
||||
...users[userIndex],
|
||||
...safeUpdates,
|
||||
};
|
||||
|
||||
// Save updated users
|
||||
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update user:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's last login time
|
||||
*/
|
||||
export async function updateLastLogin(userId: string): Promise<void> {
|
||||
await updateUser(userId, { lastLogin: new Date().toISOString() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user
|
||||
*/
|
||||
export async function deleteUser(userId: string): Promise<boolean> {
|
||||
try {
|
||||
await initializeUserStorage();
|
||||
|
||||
const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8');
|
||||
const { users } = JSON.parse(data) as { users: UserProfile[] };
|
||||
|
||||
const userIndex = users.findIndex((u) => u.id === userId);
|
||||
|
||||
if (userIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deletedUser = users[userIndex];
|
||||
|
||||
// Remove user from list
|
||||
users.splice(userIndex, 1);
|
||||
|
||||
// Save updated users
|
||||
await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2));
|
||||
|
||||
// Delete user data directory
|
||||
const userDataDir = path.join(USER_DATA_DIR, userId);
|
||||
|
||||
try {
|
||||
await fs.rm(userDataDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete user data directory: ${error}`);
|
||||
}
|
||||
|
||||
// Log the deletion
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId,
|
||||
username: deletedUser.username,
|
||||
action: 'delete',
|
||||
details: `User ${deletedUser.username} deleted`,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user-specific data
|
||||
*/
|
||||
export async function saveUserData(userId: string, key: string, data: any): Promise<void> {
|
||||
try {
|
||||
const userDataDir = path.join(USER_DATA_DIR, userId);
|
||||
await fs.mkdir(userDataDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(userDataDir, `${key}.json`);
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`Failed to save user data for ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user-specific data
|
||||
*/
|
||||
export async function loadUserData(userId: string, key: string): Promise<any | null> {
|
||||
try {
|
||||
const filePath = path.join(USER_DATA_DIR, userId, `${key}.json`);
|
||||
const data = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security events
|
||||
*/
|
||||
export async function logSecurityEvent(event: SecurityLog): Promise<void> {
|
||||
try {
|
||||
const logFile = path.join(USERS_DIR, 'security.log');
|
||||
const logEntry = `${JSON.stringify(event)}\n`;
|
||||
|
||||
await fs.appendFile(logFile, logEntry);
|
||||
} catch (error) {
|
||||
console.error('Failed to log security event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security logs
|
||||
*/
|
||||
export async function getSecurityLogs(limit: number = 100): Promise<SecurityLog[]> {
|
||||
try {
|
||||
const logFile = path.join(USERS_DIR, 'security.log');
|
||||
const data = await fs.readFile(logFile, 'utf-8');
|
||||
|
||||
const logs = data
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as SecurityLog;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as SecurityLog[];
|
||||
|
||||
return logs.slice(-limit).reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,173 +1,28 @@
|
||||
import { json, type MetaFunction } from '@remix-run/cloudflare';
|
||||
import { useLoaderData } from '@remix-run/react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { BaseChat } from '~/components/chat/BaseChat';
|
||||
import { Chat } from '~/components/chat/Chat.client';
|
||||
import { Header } from '~/components/header/Header';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { providersStore } from '~/lib/stores/settings';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: 'Bolt.gives' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Build web applications with AI assistance - Enhanced fork with advanced features',
|
||||
},
|
||||
];
|
||||
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
|
||||
};
|
||||
|
||||
export const loader = ({ context }: { context: any }) => {
|
||||
// Check which local providers are configured
|
||||
const configuredProviders: string[] = [];
|
||||
|
||||
// Check Ollama
|
||||
if (context.cloudflare?.env?.OLLAMA_API_BASE_URL || process.env?.OLLAMA_API_BASE_URL) {
|
||||
configuredProviders.push('Ollama');
|
||||
}
|
||||
|
||||
// Check LMStudio
|
||||
if (context.cloudflare?.env?.LMSTUDIO_API_BASE_URL || process.env?.LMSTUDIO_API_BASE_URL) {
|
||||
configuredProviders.push('LMStudio');
|
||||
}
|
||||
|
||||
// Check OpenAILike
|
||||
if (context.cloudflare?.env?.OPENAI_LIKE_API_BASE_URL || process.env?.OPENAI_LIKE_API_BASE_URL) {
|
||||
configuredProviders.push('OpenAILike');
|
||||
}
|
||||
|
||||
return json({ configuredProviders });
|
||||
};
|
||||
export const loader = () => json({});
|
||||
|
||||
/**
|
||||
* Landing page component for Bolt.gives
|
||||
* Enhanced fork with multi-user authentication, advanced features, and provider auto-detection
|
||||
* Landing page component for Bolt
|
||||
* Note: Settings functionality should ONLY be accessed through the sidebar menu.
|
||||
* Do not add settings button/panel to this landing page as it was intentionally removed
|
||||
* to keep the UI clean and consistent with the design system.
|
||||
*/
|
||||
export default function Index() {
|
||||
const data = useLoaderData<{ configuredProviders: string[] }>();
|
||||
const [showMultiUserBanner, setShowMultiUserBanner] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Enable configured providers if they haven't been manually configured yet
|
||||
if (data?.configuredProviders && data.configuredProviders.length > 0) {
|
||||
const savedSettings = localStorage.getItem('provider_settings');
|
||||
|
||||
if (!savedSettings) {
|
||||
// No saved settings, so enable the configured providers
|
||||
const currentProviders = providersStore.get();
|
||||
data.configuredProviders.forEach((providerName) => {
|
||||
if (currentProviders[providerName]) {
|
||||
providersStore.setKey(providerName, {
|
||||
...currentProviders[providerName],
|
||||
settings: {
|
||||
...currentProviders[providerName].settings,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Save to localStorage so this only happens once
|
||||
localStorage.setItem('provider_settings', JSON.stringify(providersStore.get()));
|
||||
}
|
||||
}
|
||||
}, [data?.configuredProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const authState = authStore.get();
|
||||
|
||||
// Show banner only if not authenticated and hasn't been dismissed
|
||||
const bannerDismissed = localStorage.getItem('multiUserBannerDismissed');
|
||||
|
||||
if (!authState.isAuthenticated && !bannerDismissed) {
|
||||
setTimeout(() => setShowMultiUserBanner(true), 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActivateMultiUser = () => {
|
||||
navigate('/auth');
|
||||
};
|
||||
|
||||
const handleDismissBanner = () => {
|
||||
setShowMultiUserBanner(false);
|
||||
localStorage.setItem('multiUserBannerDismissed', 'true');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
|
||||
<BackgroundRays />
|
||||
<Header />
|
||||
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
|
||||
|
||||
{/* Optional Multi-User Activation Banner */}
|
||||
<AnimatePresence>
|
||||
{showMultiUserBanner && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="fixed bottom-6 right-6 max-w-sm z-50"
|
||||
>
|
||||
<div className="bg-bolt-elements-background-depth-2 backdrop-blur-xl rounded-xl border border-bolt-elements-borderColor shadow-2xl p-4">
|
||||
<button
|
||||
onClick={handleDismissBanner}
|
||||
className="absolute top-2 right-2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-center shadow-lg">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-bolt-elements-textPrimary mb-1">
|
||||
Unlock Multi-User Features
|
||||
</h3>
|
||||
<p className="text-xs text-bolt-elements-textSecondary mb-3">
|
||||
Save your projects, personalized settings, and collaborate with workspace isolation.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleActivateMultiUser}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 text-white hover:from-blue-600 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Activate Now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismissBanner}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover transition-all"
|
||||
>
|
||||
Continue as Guest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
import { ProtectedRoute } from '~/components/auth/ProtectedRoute';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
avatar?: string;
|
||||
createdAt: string;
|
||||
lastLogin?: string;
|
||||
}
|
||||
|
||||
export default function UserManagement() {
|
||||
const navigate = useNavigate();
|
||||
const authState = useStore(authStore);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authState.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { users: User[] };
|
||||
setUsers(data.users);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!selectedUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${selectedUser.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authState.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setUsers(users.filter((u) => u.id !== selectedUser.id));
|
||||
setShowDeleteModal(false);
|
||||
setSelectedUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.firstName.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-bolt-elements-background-depth-1">
|
||||
{/* Header */}
|
||||
<header className="border-b border-bolt-elements-borderColor bg-bolt-elements-background-depth-2">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="p-2 rounded-lg hover:bg-bolt-elements-background-depth-3 transition-colors"
|
||||
>
|
||||
<span className="i-ph:arrow-left text-xl text-bolt-elements-textPrimary" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-bolt-elements-textPrimary">User Management</h1>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Manage system users</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={classNames(
|
||||
'w-64 px-4 py-2 pl-10 rounded-lg',
|
||||
'bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500',
|
||||
)}
|
||||
/>
|
||||
<span className="absolute left-3 top-2.5 i-ph:magnifying-glass text-bolt-elements-textTertiary" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/auth')}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg',
|
||||
'bg-accent-500 text-white',
|
||||
'hover:bg-accent-600',
|
||||
'transition-colors',
|
||||
'flex items-center gap-2',
|
||||
)}
|
||||
>
|
||||
<span className="i-ph:plus text-lg" />
|
||||
<span>Add User</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* User Stats */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">Total Users</p>
|
||||
<p className="text-2xl font-bold text-bolt-elements-textPrimary">{users.length}</p>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">Active Today</p>
|
||||
<p className="text-2xl font-bold text-green-500">
|
||||
{
|
||||
users.filter((u) => {
|
||||
if (!u.lastLogin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastLogin = new Date(u.lastLogin);
|
||||
const today = new Date();
|
||||
|
||||
return lastLogin.toDateString() === today.toDateString();
|
||||
}).length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">New This Week</p>
|
||||
<p className="text-2xl font-bold text-blue-500">
|
||||
{
|
||||
users.filter((u) => {
|
||||
const created = new Date(u.createdAt);
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
return created > weekAgo;
|
||||
}).length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">Storage Used</p>
|
||||
<p className="text-2xl font-bold text-bolt-elements-textPrimary">0 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User List */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<span className="i-ph:users text-4xl text-bolt-elements-textTertiary mb-4" />
|
||||
<p className="text-bolt-elements-textSecondary">
|
||||
{searchQuery ? 'No users found matching your search' : 'No users yet'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence>
|
||||
{filteredUsers.map((user, index) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
className={classNames(
|
||||
'bg-bolt-elements-background-depth-2 rounded-lg p-6',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'hover:shadow-lg transition-all',
|
||||
user.id === authState.user?.id ? 'ring-2 ring-accent-500' : '',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-bolt-elements-background-depth-3 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.firstName} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{user.firstName[0].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-bolt-elements-textPrimary">
|
||||
{user.firstName}
|
||||
{user.id === authState.user?.id && (
|
||||
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-accent-500/20 text-accent-500">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
|
||||
title="Edit user"
|
||||
>
|
||||
<span className="i-ph:pencil text-bolt-elements-textSecondary" />
|
||||
</button>
|
||||
{user.id !== authState.user?.id && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-red-500/10 transition-colors"
|
||||
title="Delete user"
|
||||
>
|
||||
<span className="i-ph:trash text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
||||
<span className="i-ph:calendar-blank" />
|
||||
<span>Joined {new Date(user.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{user.lastLogin && (
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
||||
<span className="i-ph:clock" />
|
||||
<span>Last active {new Date(user.lastLogin).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<AnimatePresence>
|
||||
{showDeleteModal && selectedUser && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => !deleting && setShowDeleteModal(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.95 }}
|
||||
className="bg-bolt-elements-background-depth-2 rounded-lg p-6 max-w-md w-full border border-bolt-elements-borderColor"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-bolt-elements-textPrimary mb-2">Delete User</h2>
|
||||
<p className="text-bolt-elements-textSecondary mb-6">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="font-medium text-bolt-elements-textPrimary">@{selectedUser.username}</span>? This
|
||||
action cannot be undone and will permanently remove all user data.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
disabled={deleting}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 rounded-lg',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-3',
|
||||
'disabled:opacity-50',
|
||||
'transition-colors',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteUser}
|
||||
disabled={deleting}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 rounded-lg',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
'disabled:opacity-50',
|
||||
'transition-colors',
|
||||
'flex items-center justify-center gap-2',
|
||||
)}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<span className="i-svg-spinners:3-dots-scale" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="i-ph:trash" />
|
||||
Delete User
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { getUserByUsername, updateLastLogin, logSecurityEvent } from '~/lib/utils/fileUserStorage';
|
||||
import { verifyPassword, generateToken } from '~/lib/utils/crypto';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as { username?: string; password?: string };
|
||||
const { username, password } = body;
|
||||
|
||||
if (!username || !password) {
|
||||
return json({ error: 'Username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get user from storage
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
// Log failed login attempt
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
username,
|
||||
action: 'failed_login',
|
||||
details: `Failed login attempt for non-existent user: ${username}`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Invalid username or password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
// Log failed login attempt
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'failed_login',
|
||||
details: `Failed login attempt with incorrect password`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Invalid username or password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
await updateLastLogin(user.id);
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
});
|
||||
|
||||
// Log successful login
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'login',
|
||||
details: 'Successful login',
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
// Return user data without password
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: userWithoutPassword,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'error',
|
||||
details: `Login error: ${error}`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { logSecurityEvent } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get token from Authorization header
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (payload) {
|
||||
// Log logout event
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: payload.userId,
|
||||
username: payload.username,
|
||||
action: 'logout',
|
||||
details: 'User logged out',
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { createUser, getUserByUsername, logSecurityEvent } from '~/lib/utils/fileUserStorage';
|
||||
import { validatePassword, generateToken } from '~/lib/utils/crypto';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
username?: string;
|
||||
password?: string;
|
||||
firstName?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
const { username, password, firstName, avatar } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!username || !password || !firstName) {
|
||||
return json({ error: 'Username, password, and first name are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
|
||||
return json(
|
||||
{
|
||||
error: 'Username must be 3-20 characters and contain only letters, numbers, and underscores',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePassword(password);
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return json({ error: passwordValidation.errors.join('. ') }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = await getUserByUsername(username);
|
||||
|
||||
if (existingUser) {
|
||||
return json({ error: 'Username already exists' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const user = await createUser(username, password, firstName, avatar);
|
||||
|
||||
if (!user) {
|
||||
return json({ error: 'Failed to create user' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
});
|
||||
|
||||
// Log successful signup
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'signup',
|
||||
details: 'New user registration',
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
// Return user data without password
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: userWithoutPassword,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Signup error:', error);
|
||||
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'error',
|
||||
details: `Signup error: ${error}`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { getUserById } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get token from Authorization header
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return json({ error: 'No token provided' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user from storage
|
||||
const user = await getUserById(payload.userId);
|
||||
|
||||
if (!user) {
|
||||
return json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Return user data without password
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: userWithoutPassword,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { createSummary } from '~/lib/.server/llm/create-summary';
|
||||
import { extractPropertiesFromMessage } from '~/lib/.server/llm/utils';
|
||||
import type { DesignScheme } from '~/types/design-scheme';
|
||||
import { MCPService } from '~/lib/services/mcpService';
|
||||
import { StreamRecoveryManager } from '~/lib/.server/llm/stream-recovery';
|
||||
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
return chatAction(args);
|
||||
@@ -75,22 +74,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
const encoder: TextEncoder = new TextEncoder();
|
||||
let progressCounter: number = 1;
|
||||
|
||||
// Initialize stream recovery manager
|
||||
const recovery = new StreamRecoveryManager({
|
||||
maxRetries: 3,
|
||||
retryDelay: 2000,
|
||||
timeout: 45000, // 45 seconds timeout
|
||||
onTimeout: () => {
|
||||
logger.warn('Stream timeout detected - attempting recovery');
|
||||
},
|
||||
onRetry: (attempt) => {
|
||||
logger.info(`Stream recovery attempt ${attempt}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Stream error in recovery:', error);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const mcpService = MCPService.getInstance();
|
||||
const totalMessageContent = messages.reduce((acc, message) => acc + message.content, '');
|
||||
@@ -330,77 +313,28 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
recovery.startMonitoring();
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === 'error') {
|
||||
const error: any = part.error;
|
||||
logger.error('Streaming error:', error);
|
||||
|
||||
let lastActivityTime = Date.now();
|
||||
const activityCheckInterval = 5000; // Check every 5 seconds
|
||||
|
||||
// Set up activity monitoring
|
||||
const activityChecker = setInterval(() => {
|
||||
const timeSinceLastActivity = Date.now() - lastActivityTime;
|
||||
|
||||
if (timeSinceLastActivity > 30000) {
|
||||
logger.warn(`No stream activity for ${timeSinceLastActivity}ms`);
|
||||
|
||||
// Attempt to recover if stream appears stuck
|
||||
recovery.attemptRecovery();
|
||||
// Enhanced error handling for common streaming issues
|
||||
if (error.message?.includes('Invalid JSON response')) {
|
||||
logger.error('Invalid JSON response detected - likely malformed API response');
|
||||
} else if (error.message?.includes('token')) {
|
||||
logger.error('Token-related error detected - possible token limit exceeded');
|
||||
}
|
||||
}, activityCheckInterval);
|
||||
|
||||
for await (const part of result.fullStream) {
|
||||
// Record activity
|
||||
lastActivityTime = Date.now();
|
||||
recovery.recordActivity();
|
||||
|
||||
if (part.type === 'error') {
|
||||
const error: any = part.error;
|
||||
logger.error('Streaming error:', error);
|
||||
|
||||
// Enhanced error handling for common streaming issues
|
||||
if (error.message?.includes('Invalid JSON response')) {
|
||||
logger.error('Invalid JSON response detected - likely malformed API response');
|
||||
} else if (error.message?.includes('token')) {
|
||||
logger.error('Token-related error detected - possible token limit exceeded');
|
||||
}
|
||||
|
||||
// Attempt recovery for certain errors
|
||||
const canRecover = await recovery.handleError(error);
|
||||
|
||||
if (!canRecover) {
|
||||
clearInterval(activityChecker);
|
||||
recovery.stop();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up
|
||||
clearInterval(activityChecker);
|
||||
recovery.stop();
|
||||
} catch (streamError) {
|
||||
logger.error('Fatal stream error:', streamError);
|
||||
recovery.stop();
|
||||
throw streamError;
|
||||
}
|
||||
})();
|
||||
result.mergeIntoDataStream(dataStream);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Stop recovery manager on error
|
||||
recovery.stop();
|
||||
|
||||
// Provide more specific error messages for common issues
|
||||
const errorMessage = error.message || 'Unknown error';
|
||||
|
||||
// Log detailed error for debugging
|
||||
logger.error('Chat API error:', {
|
||||
message: errorMessage,
|
||||
stack: error.stack,
|
||||
code: error.code,
|
||||
});
|
||||
|
||||
if (errorMessage.includes('model') && errorMessage.includes('not found')) {
|
||||
return 'Custom error: Invalid model selected. Please check that the model name is correct and available.';
|
||||
}
|
||||
@@ -426,11 +360,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
if (errorMessage.includes('network') || errorMessage.includes('timeout')) {
|
||||
return 'Custom error: Network error or timeout. The connection was interrupted. Please try again or switch to a different AI model.';
|
||||
}
|
||||
|
||||
if (errorMessage.includes('stream') || errorMessage.includes('hang')) {
|
||||
return 'Custom error: The conversation stream was interrupted. Please refresh the page and try again.';
|
||||
return 'Custom error: Network error. Please check your internet connection and try again.';
|
||||
}
|
||||
|
||||
return `Custom error: ${errorMessage}`;
|
||||
@@ -473,32 +403,17 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Set up cleanup for recovery manager
|
||||
const cleanupStream = dataStream.pipeThrough(
|
||||
new TransformStream({
|
||||
flush() {
|
||||
recovery.stop();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return new Response(cleanupStream, {
|
||||
return new Response(dataStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
Connection: 'keep-alive',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Text-Encoding': 'chunked',
|
||||
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Fatal error in chat API:', error);
|
||||
|
||||
// Ensure recovery manager is stopped on error
|
||||
if (typeof recovery !== 'undefined') {
|
||||
recovery.stop();
|
||||
}
|
||||
logger.error(error);
|
||||
|
||||
const errorResponse = {
|
||||
error: true,
|
||||
|
||||
@@ -8,7 +8,6 @@ interface ModelsResponse {
|
||||
modelList: ModelInfo[];
|
||||
providers: ProviderInfo[];
|
||||
defaultProvider: ProviderInfo;
|
||||
configuredProviders?: string[];
|
||||
}
|
||||
|
||||
let cachedProviders: ProviderInfo[] | null = null;
|
||||
@@ -83,28 +82,9 @@ export async function loader({
|
||||
});
|
||||
}
|
||||
|
||||
// Check which local providers are configured in environment
|
||||
const configuredProviders: string[] = [];
|
||||
|
||||
// Check Ollama
|
||||
if (context.cloudflare?.env?.OLLAMA_API_BASE_URL || process.env?.OLLAMA_API_BASE_URL) {
|
||||
configuredProviders.push('Ollama');
|
||||
}
|
||||
|
||||
// Check LMStudio
|
||||
if (context.cloudflare?.env?.LMSTUDIO_API_BASE_URL || process.env?.LMSTUDIO_API_BASE_URL) {
|
||||
configuredProviders.push('LMStudio');
|
||||
}
|
||||
|
||||
// Check OpenAILike
|
||||
if (context.cloudflare?.env?.OPENAI_LIKE_API_BASE_URL || process.env?.OPENAI_LIKE_API_BASE_URL) {
|
||||
configuredProviders.push('OpenAILike');
|
||||
}
|
||||
|
||||
return json<ModelsResponse>({
|
||||
modelList,
|
||||
providers,
|
||||
defaultProvider,
|
||||
configuredProviders,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* Netlify Quick Deploy API Endpoint
|
||||
* Contributed by Keoma Wright
|
||||
*
|
||||
* This endpoint handles quick deployments to Netlify without requiring authentication,
|
||||
* using Netlify's drop API for instant deployment.
|
||||
*/
|
||||
|
||||
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface QuickDeployRequestBody {
|
||||
files: Record<string, string>;
|
||||
chatId: string;
|
||||
framework?: string;
|
||||
}
|
||||
|
||||
// Use environment variable or fallback to public token for quick deploys
|
||||
const NETLIFY_QUICK_DEPLOY_TOKEN = process.env.NETLIFY_QUICK_DEPLOY_TOKEN || '';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const { files, chatId, framework } = (await request.json()) as QuickDeployRequestBody;
|
||||
|
||||
if (!files || Object.keys(files).length === 0) {
|
||||
return json({ error: 'No files to deploy' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Generate a unique site name
|
||||
const siteName = `bolt-quick-${chatId.substring(0, 8)}-${Date.now()}`;
|
||||
|
||||
// Prepare files for Netlify Drop API
|
||||
const deployFiles: Record<string, string> = {};
|
||||
|
||||
// Add index.html if it doesn't exist (for static sites)
|
||||
if (!files['/index.html'] && !files['index.html']) {
|
||||
// Check if there's any HTML file
|
||||
const htmlFile = Object.keys(files).find((f) => f.endsWith('.html'));
|
||||
|
||||
if (!htmlFile) {
|
||||
// Create a basic index.html
|
||||
deployFiles['/index.html'] = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${framework || 'Bolt'} App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Check if there's a main.js or app.js
|
||||
const scripts = ${JSON.stringify(Object.keys(files).filter((f) => f.endsWith('.js')))};
|
||||
if (scripts.length > 0) {
|
||||
const script = document.createElement('script');
|
||||
script.src = scripts[0];
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Process and normalize file paths
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
||||
deployFiles[normalizedPath] = content;
|
||||
}
|
||||
|
||||
// Use Netlify's API to create a new site and deploy
|
||||
let siteId: string | undefined;
|
||||
let deployUrl: string | undefined;
|
||||
|
||||
if (NETLIFY_QUICK_DEPLOY_TOKEN) {
|
||||
// If we have a token, use the authenticated API
|
||||
try {
|
||||
// Create a new site
|
||||
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: siteName,
|
||||
custom_domain: null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (createSiteResponse.ok) {
|
||||
const site = (await createSiteResponse.json()) as any;
|
||||
siteId = site.id;
|
||||
|
||||
// Create file digests for deployment
|
||||
const fileDigests: Record<string, string> = {};
|
||||
|
||||
for (const [path, content] of Object.entries(deployFiles)) {
|
||||
const hash = crypto.createHash('sha1').update(content).digest('hex');
|
||||
fileDigests[path] = hash;
|
||||
}
|
||||
|
||||
// Create deployment
|
||||
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/deploys`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: fileDigests,
|
||||
async: false,
|
||||
draft: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (deployResponse.ok) {
|
||||
const deploy = (await deployResponse.json()) as any;
|
||||
|
||||
// Upload files
|
||||
for (const [path, content] of Object.entries(deployFiles)) {
|
||||
await fetch(`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
}
|
||||
|
||||
deployUrl = deploy.ssl_url || deploy.url || `https://${siteName}.netlify.app`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error with authenticated deployment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Netlify Drop (no authentication required)
|
||||
if (!deployUrl) {
|
||||
// Create a form data with files
|
||||
const formData = new FormData();
|
||||
|
||||
// Add each file to the form data
|
||||
for (const [path, content] of Object.entries(deployFiles)) {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const fileName = path.startsWith('/') ? path.substring(1) : path;
|
||||
formData.append('file', blob, fileName);
|
||||
}
|
||||
|
||||
// Deploy using Netlify Drop API (no auth required)
|
||||
const dropResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (dropResponse.ok) {
|
||||
const dropData = (await dropResponse.json()) as any;
|
||||
siteId = dropData.id;
|
||||
deployUrl = dropData.ssl_url || dropData.url || `https://${dropData.subdomain}.netlify.app`;
|
||||
} else {
|
||||
// Try alternative deployment method
|
||||
const zipContent = await createZipArchive(deployFiles);
|
||||
|
||||
const zipResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
},
|
||||
body: zipContent,
|
||||
});
|
||||
|
||||
if (zipResponse.ok) {
|
||||
const zipData = (await zipResponse.json()) as any;
|
||||
siteId = zipData.id;
|
||||
deployUrl = zipData.ssl_url || zipData.url;
|
||||
} else {
|
||||
throw new Error('Failed to deploy to Netlify');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!deployUrl) {
|
||||
return json({ error: 'Deployment failed - could not get deployment URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
url: deployUrl,
|
||||
siteId,
|
||||
siteName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Quick deploy error:', error);
|
||||
return json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : 'Deployment failed',
|
||||
details: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create a simple ZIP archive (minimal implementation)
|
||||
async function createZipArchive(files: Record<string, string>): Promise<ArrayBuffer> {
|
||||
// This is a simplified ZIP creation - in production, use a proper ZIP library
|
||||
const encoder = new TextEncoder();
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
// For simplicity, we'll create a tar-like format
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
const pathBytes = encoder.encode(path);
|
||||
const contentBytes = encoder.encode(content);
|
||||
|
||||
// Simple header: path length (4 bytes) + content length (4 bytes)
|
||||
const header = new Uint8Array(8);
|
||||
new DataView(header.buffer).setUint32(0, pathBytes.length, true);
|
||||
new DataView(header.buffer).setUint32(4, contentBytes.length, true);
|
||||
|
||||
parts.push(header);
|
||||
parts.push(pathBytes);
|
||||
parts.push(contentBytes);
|
||||
}
|
||||
|
||||
// Combine all parts
|
||||
const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { deleteUser } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
try {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return json({ error: 'User ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Prevent users from deleting themselves
|
||||
if (payload.userId === id) {
|
||||
return json({ error: 'Cannot delete your own account' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
// Delete the user
|
||||
const success = await deleteUser(id);
|
||||
|
||||
if (success) {
|
||||
return json({ success: true });
|
||||
} else {
|
||||
return json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
} catch (error) {
|
||||
console.error('User operation error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { getAllUsers } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
// Verify authentication
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all users (without passwords)
|
||||
const users = await getAllUsers();
|
||||
|
||||
return json({ users });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { login, signup } from '~/lib/stores/auth';
|
||||
import { validatePassword } from '~/lib/utils/crypto';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
export default function AuthPage() {
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
confirmPassword: '',
|
||||
rememberMe: false,
|
||||
});
|
||||
const [avatar, setAvatar] = useState<string | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
|
||||
// Clear error for this field
|
||||
setErrors((prev) => ({ ...prev, [name]: '' }));
|
||||
};
|
||||
|
||||
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = () => {
|
||||
setAvatar(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (mode === 'signup') {
|
||||
// Validate form
|
||||
const validationErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.username) {
|
||||
validationErrors.username = 'Username is required';
|
||||
}
|
||||
|
||||
if (!formData.firstName) {
|
||||
validationErrors.firstName = 'First name is required';
|
||||
}
|
||||
|
||||
const passwordValidation = validatePassword(formData.password);
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
validationErrors.password = passwordValidation.errors[0];
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
validationErrors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signup(formData.username, formData.password, formData.firstName, avatar);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setErrors({ general: result.error || 'Signup failed' });
|
||||
}
|
||||
} else {
|
||||
const result = await login(formData.username, formData.password, formData.rememberMe);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setErrors({ general: result.error || 'Invalid username or password' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setErrors({ general: 'An error occurred. Please try again.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||
{/* Animated gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 via-purple-600 to-pink-600">
|
||||
<div className="absolute inset-0 bg-black/30" />
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-30"
|
||||
animate={{
|
||||
background: [
|
||||
'radial-gradient(circle at 20% 80%, #3b82f6 0%, transparent 50%)',
|
||||
'radial-gradient(circle at 80% 20%, #a855f7 0%, transparent 50%)',
|
||||
'radial-gradient(circle at 40% 40%, #ec4899 0%, transparent 50%)',
|
||||
'radial-gradient(circle at 20% 80%, #3b82f6 0%, transparent 50%)',
|
||||
],
|
||||
}}
|
||||
transition={{ duration: 10, repeat: Infinity }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo and Title */}
|
||||
<div className="absolute top-8 left-8 z-20">
|
||||
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
|
||||
<span className="text-2xl">⚡</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">bolt.diy</h1>
|
||||
<p className="text-sm text-white/70">Multi-User Edition</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Auth Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="relative z-10 w-full max-w-md mx-4"
|
||||
>
|
||||
<div className="bg-white/10 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 overflow-hidden">
|
||||
{/* Tab Header */}
|
||||
<div className="flex relative bg-white/5">
|
||||
<button
|
||||
onClick={() => setMode('login')}
|
||||
className={classNames(
|
||||
'flex-1 py-4 text-center font-semibold transition-all',
|
||||
mode === 'login'
|
||||
? 'text-white bg-gradient-to-r from-blue-500/20 to-purple-600/20'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('signup')}
|
||||
className={classNames(
|
||||
'flex-1 py-4 text-center font-semibold transition-all',
|
||||
mode === 'signup'
|
||||
? 'text-white bg-gradient-to-r from-blue-500/20 to-purple-600/20'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
|
||||
{/* Sliding indicator */}
|
||||
<motion.div
|
||||
className="absolute bottom-0 h-1 bg-gradient-to-r from-blue-500 to-purple-600"
|
||||
initial={false}
|
||||
animate={{
|
||||
x: mode === 'login' ? '0%' : '100%',
|
||||
width: '50%',
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.form
|
||||
key={mode}
|
||||
initial={{ opacity: 0, x: mode === 'login' ? -20 : 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: mode === 'login' ? 20 : -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Avatar Upload (Signup only) */}
|
||||
{mode === 'signup' && (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 rounded-full bg-white/20 backdrop-blur flex items-center justify-center overflow-hidden border-2 border-white/30">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl text-white/50">👤</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="absolute bottom-0 right-0 w-8 h-8 bg-white/20 backdrop-blur rounded-full flex items-center justify-center cursor-pointer hover:bg-white/30 transition-colors border border-white/30">
|
||||
<span className="text-sm">📷</span>
|
||||
<input type="file" accept="image/*" onChange={handleAvatarUpload} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* First Name (Signup only) */}
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.firstName && 'border-red-400',
|
||||
)}
|
||||
placeholder="Enter your first name"
|
||||
required
|
||||
/>
|
||||
{errors.firstName && <p className="mt-1 text-sm text-red-300">{errors.firstName}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.username && 'border-red-400',
|
||||
)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
/>
|
||||
{errors.username && <p className="mt-1 text-sm text-red-300">{errors.username}</p>}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.password && 'border-red-400',
|
||||
)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
{errors.password && <p className="mt-1 text-sm text-red-300">{errors.password}</p>}
|
||||
{mode === 'signup' && formData.password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
formData.password.length >= 8 ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">At least 8 characters</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
/[A-Z]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">One uppercase letter</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
/[a-z]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">One lowercase letter</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
/[0-9]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">One number</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password (Signup only) */}
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.confirmPassword && 'border-red-400',
|
||||
)}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
/>
|
||||
{errors.confirmPassword && <p className="mt-1 text-sm text-red-300">{errors.confirmPassword}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remember Me (Login only) */}
|
||||
{mode === 'login' && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="rememberMe"
|
||||
id="rememberMe"
|
||||
checked={formData.rememberMe}
|
||||
onChange={handleInputChange}
|
||||
className="w-4 h-4 rounded bg-white/10 border-white/20 text-blue-500 focus:ring-white/50"
|
||||
/>
|
||||
<label htmlFor="rememberMe" className="ml-2 text-sm text-white/70">
|
||||
Remember me for 7 days
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errors.general && (
|
||||
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/30">
|
||||
<p className="text-sm text-red-200">{errors.general}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={classNames(
|
||||
'w-full py-3 rounded-lg font-semibold transition-all',
|
||||
'bg-gradient-to-r from-blue-500 to-purple-600 text-white',
|
||||
'hover:from-blue-600 hover:to-purple-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'flex items-center justify-center gap-2',
|
||||
'shadow-lg hover:shadow-xl',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="i-svg-spinners:3-dots-scale w-5 h-5" />
|
||||
{mode === 'login' ? 'Signing in...' : 'Creating account...'}
|
||||
</>
|
||||
) : mode === 'login' ? (
|
||||
'Sign In'
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</button>
|
||||
</motion.form>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Developer Credit */}
|
||||
<div className="mt-8 pt-6 border-t border-white/10">
|
||||
<p className="text-center text-xs text-white/40">
|
||||
Developed by <span className="text-white/60 font-medium">Keoma Wright</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Continue as Guest */}
|
||||
<div className="pb-6">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="w-full py-2 text-center text-sm text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Continue as Guest
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user