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