| 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'; |
| 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', |
| }, |
| }; |
|
|
| |
| artifact.runner.addAction(actionData); |
|
|
| |
| await artifact.runner.runAction(actionData); |
|
|
| if (!artifact.runner.buildOutput) { |
| throw new Error('Build failed'); |
| } |
|
|
| |
| const container = await webcontainer; |
|
|
| |
| const buildPath = artifact.runner.buildOutput.path.replace('/home/project', ''); |
|
|
| |
| 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'); |
|
|
| |
| 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); |
|
|
| |
| const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`); |
|
|
| |
| 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, |
| }), |
| }); |
|
|
| 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'); |
| } |
|
|
| |
| const maxAttempts = 20; |
| 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'); |
| } |
|
|
| |
| 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> |
| ); |
| } |
|
|