Spaces:
Build error
Build error
| import { useStore } from '@nanostores/react'; | |
| import { toast } from 'react-toastify'; | |
| import useViewport from '~/lib/hooks'; | |
| import { chatStore } from '~/lib/stores/chat'; | |
| import { netlifyConnection } from '~/lib/stores/netlify'; | |
| import { workbenchStore } from '~/lib/stores/workbench'; | |
| import { webcontainer } from '~/lib/webcontainer'; | |
| import { classNames } from '~/utils/classNames'; | |
| import { path } from '~/utils/path'; | |
| import { useEffect, useRef, useState } from 'react'; | |
| import type { ActionCallbackData } from '~/lib/runtime/message-parser'; | |
| import { chatId } from '~/lib/persistence/useChatHistory'; // Add this import | |
| import { streamingState } from '~/lib/stores/streaming'; | |
| import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client'; | |
| interface HeaderActionButtonsProps {} | |
| export function HeaderActionButtons({}: HeaderActionButtonsProps) { | |
| const showWorkbench = useStore(workbenchStore.showWorkbench); | |
| const { showChat } = useStore(chatStore); | |
| const connection = useStore(netlifyConnection); | |
| const [activePreviewIndex] = useState(0); | |
| const previews = useStore(workbenchStore.previews); | |
| const activePreview = previews[activePreviewIndex]; | |
| const [isDeploying, setIsDeploying] = useState(false); | |
| const isSmallViewport = useViewport(1024); | |
| const canHideChat = showWorkbench || !showChat; | |
| const [isDropdownOpen, setIsDropdownOpen] = useState(false); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const isStreaming = useStore(streamingState); | |
| useEffect(() => { | |
| function handleClickOutside(event: MouseEvent) { | |
| if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { | |
| setIsDropdownOpen(false); | |
| } | |
| } | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => document.removeEventListener('mousedown', handleClickOutside); | |
| }, []); | |
| const currentChatId = useStore(chatId); | |
| const handleDeploy = async () => { | |
| if (!connection.user || !connection.token) { | |
| toast.error('Please connect to Netlify first in the settings tab!'); | |
| return; | |
| } | |
| if (!currentChatId) { | |
| toast.error('No active chat found'); | |
| return; | |
| } | |
| try { | |
| setIsDeploying(true); | |
| const artifact = workbenchStore.firstArtifact; | |
| if (!artifact) { | |
| throw new Error('No active project found'); | |
| } | |
| const actionId = 'build-' + Date.now(); | |
| const actionData: ActionCallbackData = { | |
| messageId: 'netlify build', | |
| artifactId: artifact.id, | |
| actionId, | |
| action: { | |
| type: 'build' as const, | |
| content: 'npm run build', | |
| }, | |
| }; | |
| // Add the action first | |
| artifact.runner.addAction(actionData); | |
| // Then run it | |
| await artifact.runner.runAction(actionData); | |
| if (!artifact.runner.buildOutput) { | |
| throw new Error('Build failed'); | |
| } | |
| // Get the build files | |
| const container = await webcontainer; | |
| // Remove /home/project from buildPath if it exists | |
| const buildPath = artifact.runner.buildOutput.path.replace('/home/project', ''); | |
| // Get all files recursively | |
| async function getAllFiles(dirPath: string): Promise<Record<string, string>> { | |
| const files: Record<string, string> = {}; | |
| const entries = await container.fs.readdir(dirPath, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const fullPath = path.join(dirPath, entry.name); | |
| if (entry.isFile()) { | |
| const content = await container.fs.readFile(fullPath, 'utf-8'); | |
| // Remove /dist prefix from the path | |
| const deployPath = fullPath.replace(buildPath, ''); | |
| files[deployPath] = content; | |
| } else if (entry.isDirectory()) { | |
| const subFiles = await getAllFiles(fullPath); | |
| Object.assign(files, subFiles); | |
| } | |
| } | |
| return files; | |
| } | |
| const fileContents = await getAllFiles(buildPath); | |
| // Use chatId instead of artifact.id | |
| const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`); | |
| // Deploy using the API route with file contents | |
| const response = await fetch('/api/deploy', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| siteId: existingSiteId || undefined, | |
| files: fileContents, | |
| token: connection.token, | |
| chatId: currentChatId, // Use chatId instead of artifact.id | |
| }), | |
| }); | |
| const data = (await response.json()) as any; | |
| if (!response.ok || !data.deploy || !data.site) { | |
| console.error('Invalid deploy response:', data); | |
| throw new Error(data.error || 'Invalid deployment response'); | |
| } | |
| // Poll for deployment status | |
| const maxAttempts = 20; // 2 minutes timeout | |
| let attempts = 0; | |
| let deploymentStatus; | |
| while (attempts < maxAttempts) { | |
| try { | |
| const statusResponse = await fetch( | |
| `https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`, | |
| { | |
| headers: { | |
| Authorization: `Bearer ${connection.token}`, | |
| }, | |
| }, | |
| ); | |
| deploymentStatus = (await statusResponse.json()) as any; | |
| if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') { | |
| break; | |
| } | |
| if (deploymentStatus.state === 'error') { | |
| throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error')); | |
| } | |
| attempts++; | |
| await new Promise((resolve) => setTimeout(resolve, 1000)); | |
| } catch (error) { | |
| console.error('Status check error:', error); | |
| attempts++; | |
| await new Promise((resolve) => setTimeout(resolve, 2000)); | |
| } | |
| } | |
| if (attempts >= maxAttempts) { | |
| throw new Error('Deployment timed out'); | |
| } | |
| // Store the site ID if it's a new site | |
| if (data.site) { | |
| localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id); | |
| } | |
| toast.success( | |
| <div> | |
| Deployed successfully!{' '} | |
| <a | |
| href={deploymentStatus.ssl_url || deploymentStatus.url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="underline" | |
| > | |
| View site | |
| </a> | |
| </div>, | |
| ); | |
| } catch (error) { | |
| console.error('Deploy error:', error); | |
| toast.error(error instanceof Error ? error.message : 'Deployment failed'); | |
| } finally { | |
| setIsDeploying(false); | |
| } | |
| }; | |
| return ( | |
| <div className="flex"> | |
| <div className="relative" ref={dropdownRef}> | |
| <div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm"> | |
| <Button | |
| active | |
| disabled={isDeploying || !activePreview || isStreaming} | |
| onClick={() => setIsDropdownOpen(!isDropdownOpen)} | |
| className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2" | |
| > | |
| {isDeploying ? 'Deploying...' : 'Deploy'} | |
| <div | |
| className={classNames('i-ph:caret-down w-4 h-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')} | |
| /> | |
| </Button> | |
| </div> | |
| {isDropdownOpen && ( | |
| <div className="absolute right-2 flex flex-col gap-1 z-50 p-1 mt-1 min-w-[13.5rem] bg-bolt-elements-background-depth-2 rounded-md shadow-lg bg-bolt-elements-backgroundDefault border border-bolt-elements-borderColor"> | |
| <Button | |
| active | |
| onClick={() => { | |
| handleDeploy(); | |
| setIsDropdownOpen(false); | |
| }} | |
| disabled={isDeploying || !activePreview || !connection.user} | |
| className="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" | |
| > | |
| <img | |
| className="w-5 h-5" | |
| height="24" | |
| width="24" | |
| crossOrigin="anonymous" | |
| src="https://cdn.simpleicons.org/netlify" | |
| /> | |
| <span className="mx-auto">{!connection.user ? 'No Account Connected' : 'Deploy to Netlify'}</span> | |
| {connection.user && <NetlifyDeploymentLink />} | |
| </Button> | |
| <Button | |
| active={false} | |
| disabled | |
| className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2" | |
| > | |
| <span className="sr-only">Coming Soon</span> | |
| <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">Deploy to Vercel (Coming Soon)</span> | |
| </Button> | |
| <Button | |
| active={false} | |
| disabled | |
| className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2" | |
| > | |
| <span className="sr-only">Coming Soon</span> | |
| <img | |
| className="w-5 h-5" | |
| height="24" | |
| width="24" | |
| crossOrigin="anonymous" | |
| src="https://cdn.simpleicons.org/cloudflare" | |
| alt="vercel" | |
| /> | |
| <span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span> | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden"> | |
| <Button | |
| active={showChat} | |
| disabled={!canHideChat || isSmallViewport} // expand button is disabled on mobile as it's not needed | |
| onClick={() => { | |
| if (canHideChat) { | |
| chatStore.setKey('showChat', !showChat); | |
| } | |
| }} | |
| > | |
| <div className="i-bolt:chat text-sm" /> | |
| </Button> | |
| <div className="w-[1px] bg-bolt-elements-borderColor" /> | |
| <Button | |
| active={showWorkbench} | |
| onClick={() => { | |
| if (showWorkbench && !showChat) { | |
| chatStore.setKey('showChat', true); | |
| } | |
| workbenchStore.showWorkbench.set(!showWorkbench); | |
| }} | |
| > | |
| <div className="i-ph:code-bold" /> | |
| </Button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| interface ButtonProps { | |
| active?: boolean; | |
| disabled?: boolean; | |
| children?: any; | |
| onClick?: VoidFunction; | |
| className?: string; | |
| } | |
| function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) { | |
| return ( | |
| <button | |
| className={classNames( | |
| 'flex items-center p-1.5', | |
| { | |
| 'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': | |
| !active, | |
| 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled, | |
| 'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed': | |
| disabled, | |
| }, | |
| className, | |
| )} | |
| onClick={onClick} | |
| > | |
| {children} | |
| </button> | |
| ); | |
| } | |