| | 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> |
| | ); |
| | } |
| |
|