diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 0b54001c..42070f9f 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -1,14 +1,11 @@ ---- name: Docker Publish on: - workflow_dispatch: push: branches: - main - tags: - - v* - - '*' + - stable + workflow_dispatch: permissions: packages: write @@ -16,66 +13,49 @@ permissions: env: REGISTRY: ghcr.io - DOCKER_IMAGE: ghcr.io/${{ github.repository }} - BUILD_TARGET: bolt-ai-production # bolt-ai-development + IMAGE_NAME: ${{ github.repository }} jobs: docker-build-publish: runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout code uses: actions/checkout@v4 - - id: string - uses: ASzc/change-string-case-action@v6 - with: - string: ${{ env.DOCKER_IMAGE }} - - - name: Docker meta - id: meta - uses: crazy-max/ghaction-docker-meta@v5 - with: - images: ${{ steps.string.outputs.lowercase }} - flavor: | - latest=true - prefix= - suffix= - tags: | - type=semver,pattern={{version}} - type=pep440,pattern={{version}} - type=ref,event=tag - type=raw,value={{sha}} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to Container Registry + - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} # ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push + - name: Extract metadata for Docker image + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image for main + if: github.ref == 'refs/heads/main' uses: docker/build-push-action@v6 with: context: . - file: ./Dockerfile - target: ${{ env.BUILD_TARGET }} - platforms: linux/amd64,linux/arm64 push: true - tags: ${{ steps.meta.outputs.tags }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ steps.string.outputs.lowercase }}:latest - cache-to: type=inline - - name: Check manifest - run: | - docker buildx imagetools inspect ${{ steps.string.outputs.lowercase }}:${{ steps.meta.outputs.version }} - - - name: Dump context - if: always() - uses: crazy-max/ghaction-dump-context@v2 + - name: Build and push Docker image for stable + if: github.ref == 'refs/heads/stable' + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index 99e6f1b5..1cd3f0bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,10 @@ WORKDIR /app # Install dependencies (this step is cached as long as the dependencies don't change) COPY package.json pnpm-lock.yaml ./ -RUN npm install -g corepack@latest +#RUN npm install -g corepack@latest -RUN corepack enable pnpm && pnpm install +#RUN corepack enable pnpm && pnpm install +RUN npm install -g pnpm && pnpm install # Copy the rest of your app's source code COPY . . diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx index caaedce5..450d241a 100644 --- a/app/components/@settings/tabs/connections/ConnectionsTab.tsx +++ b/app/components/@settings/tabs/connections/ConnectionsTab.tsx @@ -1,244 +1,8 @@ -import React, { useState, useEffect } from 'react'; -import { logStore } from '~/lib/stores/logs'; -import { classNames } from '~/utils/classNames'; import { motion } from 'framer-motion'; -import { toast } from 'react-toastify'; - -interface GitHubUserResponse { - login: string; - avatar_url: string; - html_url: string; - name: string; - bio: string; - public_repos: number; - followers: number; - following: number; - created_at: string; - public_gists: number; -} - -interface GitHubRepoInfo { - name: string; - full_name: string; - html_url: string; - description: string; - stargazers_count: number; - forks_count: number; - default_branch: string; - updated_at: string; - languages_url: string; -} - -interface GitHubOrganization { - login: string; - avatar_url: string; - html_url: string; -} - -interface GitHubEvent { - id: string; - type: string; - repo: { - name: string; - }; - created_at: string; -} - -interface GitHubLanguageStats { - [language: string]: number; -} - -interface GitHubStats { - repos: GitHubRepoInfo[]; - totalStars: number; - totalForks: number; - organizations: GitHubOrganization[]; - recentActivity: GitHubEvent[]; - languages: GitHubLanguageStats; - totalGists: number; -} - -interface GitHubConnection { - user: GitHubUserResponse | null; - token: string; - tokenType: 'classic' | 'fine-grained'; - stats?: GitHubStats; -} +import { GithubConnection } from './GithubConnection'; +import { NetlifyConnection } from './NetlifyConnection'; export default function ConnectionsTab() { - const [connection, setConnection] = useState({ - user: null, - token: '', - tokenType: 'classic', - }); - const [isLoading, setIsLoading] = useState(true); - const [isConnecting, setIsConnecting] = useState(false); - const [isFetchingStats, setIsFetchingStats] = useState(false); - - // Load saved connection on mount - useEffect(() => { - const savedConnection = localStorage.getItem('github_connection'); - - if (savedConnection) { - const parsed = JSON.parse(savedConnection); - - // Ensure backward compatibility with existing connections - if (!parsed.tokenType) { - parsed.tokenType = 'classic'; - } - - setConnection(parsed); - - if (parsed.user && parsed.token) { - fetchGitHubStats(parsed.token); - } - } - - setIsLoading(false); - }, []); - - const fetchGitHubStats = async (token: string) => { - try { - setIsFetchingStats(true); - - // Fetch repositories - only owned by the authenticated user - const reposResponse = await fetch( - 'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator', - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - if (!reposResponse.ok) { - throw new Error('Failed to fetch repositories'); - } - - const repos = (await reposResponse.json()) as GitHubRepoInfo[]; - - // Fetch organizations - const orgsResponse = await fetch('https://api.github.com/user/orgs', { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!orgsResponse.ok) { - throw new Error('Failed to fetch organizations'); - } - - const organizations = (await orgsResponse.json()) as GitHubOrganization[]; - - // Fetch recent activity - const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!eventsResponse.ok) { - throw new Error('Failed to fetch events'); - } - - const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5); - - // Fetch languages for each repository - const languagePromises = repos.map((repo) => - fetch(repo.languages_url, { - headers: { - Authorization: `Bearer ${token}`, - }, - }).then((res) => res.json() as Promise>), - ); - - const repoLanguages = await Promise.all(languagePromises); - const languages: GitHubLanguageStats = {}; - - repoLanguages.forEach((repoLang) => { - Object.entries(repoLang).forEach(([lang, bytes]) => { - languages[lang] = (languages[lang] || 0) + bytes; - }); - }); - - // Calculate total stats - const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0); - const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0); - const totalGists = connection.user?.public_gists || 0; - - setConnection((prev) => ({ - ...prev, - stats: { - repos, - totalStars, - totalForks, - organizations, - recentActivity, - languages, - totalGists, - }, - })); - } catch (error) { - logStore.logError('Failed to fetch GitHub stats', { error }); - toast.error('Failed to fetch GitHub statistics'); - } finally { - setIsFetchingStats(false); - } - }; - - const fetchGithubUser = async (token: string) => { - try { - setIsConnecting(true); - - const response = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - throw new Error('Invalid token or unauthorized'); - } - - const data = (await response.json()) as GitHubUserResponse; - const newConnection: GitHubConnection = { - user: data, - token, - tokenType: connection.tokenType, - }; - - // Save connection - localStorage.setItem('github_connection', JSON.stringify(newConnection)); - setConnection(newConnection); - - // Fetch additional stats - await fetchGitHubStats(token); - - toast.success('Successfully connected to GitHub'); - } catch (error) { - logStore.logError('Failed to authenticate with GitHub', { error }); - toast.error('Failed to connect to GitHub'); - setConnection({ user: null, token: '', tokenType: 'classic' }); - } finally { - setIsConnecting(false); - } - }; - - const handleConnect = async (event: React.FormEvent) => { - event.preventDefault(); - await fetchGithubUser(connection.token); - }; - - const handleDisconnect = () => { - localStorage.removeItem('github_connection'); - setConnection({ user: null, token: '', tokenType: 'classic' }); - toast.success('Disconnected from GitHub'); - }; - - if (isLoading) { - return ; - } - return (
{/* Header */} @@ -256,359 +20,8 @@ export default function ConnectionsTab() {

- {/* GitHub Connection */} - -
-
-
-

GitHub Connection

-
- -
-
- - -
- -
- - setConnection((prev) => ({ ...prev, token: e.target.value }))} - disabled={isConnecting || !!connection.user} - placeholder={`Enter your GitHub ${connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained 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-purple-500', - 'disabled:opacity-50', - )} - /> -
- - Get your token -
- - - - Required scopes:{' '} - {connection.tokenType === 'classic' - ? 'repo, read:org, read:user' - : 'Repository access, Organization access'} - -
-
-
- -
- {!connection.user ? ( - - ) : ( - - )} - - {connection.user && ( - -
- Connected to GitHub - - )} -
- - {connection.user && ( -
-
- {connection.user.login} -
-

{connection.user.name}

-

@{connection.user.login}

-
-
- - {isFetchingStats ? ( -
-
- Fetching GitHub stats... -
- ) : ( - connection.stats && ( -
-
-

Public Repos

-

- {connection.user.public_repos} -

-
-
-

Total Stars

-

- {connection.stats.totalStars} -

-
-
-

Total Forks

-

- {connection.stats.totalForks} -

-
-
- ) - )} -
- )} - - {connection.user && connection.stats && ( -
-
- {connection.user.login} -
-

- {connection.user.name || connection.user.login} -

- {connection.user.bio && ( -

{connection.user.bio}

- )} -
- -
- {connection.user.followers} followers - - -
- {connection.stats.totalStars} stars - - -
- {connection.stats.totalForks} forks - -
-
-
- - {/* Organizations Section */} - {connection.stats.organizations.length > 0 && ( -
-

Organizations

-
- {connection.stats.organizations.map((org) => ( - - {org.login} - {org.login} - - ))} -
-
- )} - - {/* Languages Section */} -
-

Top Languages

-
- {Object.entries(connection.stats.languages) - .sort(([, a], [, b]) => b - a) - .slice(0, 5) - .map(([language]) => ( - - {language} - - ))} -
-
- - {/* Recent Activity Section */} -
-

Recent Activity

-
- {connection.stats.recentActivity.map((event) => ( -
-
-
- {event.type.replace('Event', '')} - on - - {event.repo.name} - -
-
- {new Date(event.created_at).toLocaleDateString()} at{' '} - {new Date(event.created_at).toLocaleTimeString()} -
-
- ))} -
-
- - {/* Additional Stats */} -
-
-
Member Since
-
- {new Date(connection.user.created_at).toLocaleDateString()} -
-
-
-
Public Gists
-
- {connection.stats.totalGists} -
-
-
-
Organizations
-
- {connection.stats.organizations.length} -
-
-
-
Languages
-
- {Object.keys(connection.stats.languages).length} -
-
-
- - {/* Existing repositories section */} -

Recent Repositories

- -
- ); -} - -function LoadingSpinner() { - return ( -
-
-
- Loading... + +
); diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx new file mode 100644 index 00000000..e2d8924f --- /dev/null +++ b/app/components/@settings/tabs/connections/GithubConnection.tsx @@ -0,0 +1,557 @@ +import React, { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; +import { logStore } from '~/lib/stores/logs'; +import { classNames } from '~/utils/classNames'; + +interface GitHubUserResponse { + login: string; + avatar_url: string; + html_url: string; + name: string; + bio: string; + public_repos: number; + followers: number; + following: number; + created_at: string; + public_gists: number; +} + +interface GitHubRepoInfo { + name: string; + full_name: string; + html_url: string; + description: string; + stargazers_count: number; + forks_count: number; + default_branch: string; + updated_at: string; + languages_url: string; +} + +interface GitHubOrganization { + login: string; + avatar_url: string; + html_url: string; +} + +interface GitHubEvent { + id: string; + type: string; + repo: { + name: string; + }; + created_at: string; +} + +interface GitHubLanguageStats { + [language: string]: number; +} + +interface GitHubStats { + repos: GitHubRepoInfo[]; + totalStars: number; + totalForks: number; + organizations: GitHubOrganization[]; + recentActivity: GitHubEvent[]; + languages: GitHubLanguageStats; + totalGists: number; +} + +interface GitHubConnection { + user: GitHubUserResponse | null; + token: string; + tokenType: 'classic' | 'fine-grained'; + stats?: GitHubStats; +} + +export function GithubConnection() { + const [connection, setConnection] = useState({ + user: null, + token: '', + tokenType: 'classic', + }); + const [isLoading, setIsLoading] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + const [isFetchingStats, setIsFetchingStats] = useState(false); + const [isStatsExpanded, setIsStatsExpanded] = useState(false); + + const fetchGitHubStats = async (token: string) => { + try { + setIsFetchingStats(true); + + const reposResponse = await fetch( + 'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator', + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!reposResponse.ok) { + throw new Error('Failed to fetch repositories'); + } + + const repos = (await reposResponse.json()) as GitHubRepoInfo[]; + + const orgsResponse = await fetch('https://api.github.com/user/orgs', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!orgsResponse.ok) { + throw new Error('Failed to fetch organizations'); + } + + const organizations = (await orgsResponse.json()) as GitHubOrganization[]; + + const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!eventsResponse.ok) { + throw new Error('Failed to fetch events'); + } + + const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5); + + const languagePromises = repos.map((repo) => + fetch(repo.languages_url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.json() as Promise>), + ); + + const repoLanguages = await Promise.all(languagePromises); + const languages: GitHubLanguageStats = {}; + + repoLanguages.forEach((repoLang) => { + Object.entries(repoLang).forEach(([lang, bytes]) => { + languages[lang] = (languages[lang] || 0) + bytes; + }); + }); + + const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0); + const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0); + const totalGists = connection.user?.public_gists || 0; + + setConnection((prev) => ({ + ...prev, + stats: { + repos, + totalStars, + totalForks, + organizations, + recentActivity, + languages, + totalGists, + }, + })); + } catch (error) { + logStore.logError('Failed to fetch GitHub stats', { error }); + toast.error('Failed to fetch GitHub statistics'); + } finally { + setIsFetchingStats(false); + } + }; + + useEffect(() => { + const savedConnection = localStorage.getItem('github_connection'); + + if (savedConnection) { + const parsed = JSON.parse(savedConnection); + + if (!parsed.tokenType) { + parsed.tokenType = 'classic'; + } + + setConnection(parsed); + + if (parsed.user && parsed.token) { + fetchGitHubStats(parsed.token); + } + } + + setIsLoading(false); + }, []); + + if (isLoading || isConnecting || isFetchingStats) { + return ; + } + + const fetchGithubUser = async (token: string) => { + try { + setIsConnecting(true); + + const response = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Invalid token or unauthorized'); + } + + const data = (await response.json()) as GitHubUserResponse; + const newConnection: GitHubConnection = { + user: data, + token, + tokenType: connection.tokenType, + }; + + localStorage.setItem('github_connection', JSON.stringify(newConnection)); + setConnection(newConnection); + + await fetchGitHubStats(token); + + toast.success('Successfully connected to GitHub'); + } catch (error) { + logStore.logError('Failed to authenticate with GitHub', { error }); + toast.error('Failed to connect to GitHub'); + setConnection({ user: null, token: '', tokenType: 'classic' }); + } finally { + setIsConnecting(false); + } + }; + + const handleConnect = async (event: React.FormEvent) => { + event.preventDefault(); + await fetchGithubUser(connection.token); + }; + + const handleDisconnect = () => { + localStorage.removeItem('github_connection'); + setConnection({ user: null, token: '', tokenType: 'classic' }); + toast.success('Disconnected from GitHub'); + }; + + return ( + +
+
+
+

GitHub Connection

+
+ +
+
+ + +
+ +
+ + setConnection((prev) => ({ ...prev, token: e.target.value }))} + disabled={isConnecting || !!connection.user} + placeholder={`Enter your GitHub ${ + connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained 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-purple-500', + 'disabled:opacity-50', + )} + /> +
+ + Get your token +
+ + + + Required scopes:{' '} + {connection.tokenType === 'classic' + ? 'repo, read:org, read:user' + : 'Repository access, Organization access'} + +
+
+
+ +
+ {!connection.user ? ( + + ) : ( + + )} + + {connection.user && ( + +
+ Connected to GitHub + + )} +
+ + {connection.user && connection.stats && ( +
+ + + {isStatsExpanded && ( +
+ {connection.stats.organizations.length > 0 && ( +
+

Organizations

+
+ {connection.stats.organizations.map((org) => ( + + {org.login} + {org.login} + + ))} +
+
+ )} + + {/* Languages Section */} +
+

Top Languages

+
+ {Object.entries(connection.stats.languages) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([language]) => ( + + {language} + + ))} +
+
+ + {/* Recent Activity Section */} +
+

Recent Activity

+
+ {connection.stats.recentActivity.map((event) => ( +
+
+
+ {event.type.replace('Event', '')} + on + + {event.repo.name} + +
+
+ {new Date(event.created_at).toLocaleDateString()} at{' '} + {new Date(event.created_at).toLocaleTimeString()} +
+
+ ))} +
+
+ + {/* Additional Stats */} +
+
+
Member Since
+
+ {new Date(connection.user.created_at).toLocaleDateString()} +
+
+
+
Public Gists
+
+ {connection.stats.totalGists} +
+
+
+
Organizations
+
+ {connection.stats.organizations.length} +
+
+
+
Languages
+
+ {Object.keys(connection.stats.languages).length} +
+
+
+ + {/* Repositories Section */} +

Recent Repositories

+ + + ); +} + +function LoadingSpinner() { + return ( +
+
+
+ Loading... +
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx new file mode 100644 index 00000000..5881b761 --- /dev/null +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; +import { logStore } from '~/lib/stores/logs'; +import { classNames } from '~/utils/classNames'; +import { + netlifyConnection, + isConnecting, + isFetchingStats, + updateNetlifyConnection, + fetchNetlifyStats, +} from '~/lib/stores/netlify'; +import type { NetlifyUser } from '~/types/netlify'; + +export function NetlifyConnection() { + const connection = useStore(netlifyConnection); + const connecting = useStore(isConnecting); + const fetchingStats = useStore(isFetchingStats); + const [isSitesExpanded, setIsSitesExpanded] = useState(false); + + useEffect(() => { + const fetchSites = async () => { + if (connection.user && connection.token) { + await fetchNetlifyStats(connection.token); + } + }; + fetchSites(); + }, [connection.user, connection.token]); + + const handleConnect = async (event: React.FormEvent) => { + event.preventDefault(); + isConnecting.set(true); + + try { + const response = await fetch('https://api.netlify.com/api/v1/user', { + headers: { + Authorization: `Bearer ${connection.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Invalid token or unauthorized'); + } + + const userData = (await response.json()) as NetlifyUser; + updateNetlifyConnection({ + user: userData, + token: connection.token, + }); + + await fetchNetlifyStats(connection.token); + toast.success('Successfully connected to Netlify'); + } catch (error) { + console.error('Auth error:', error); + logStore.logError('Failed to authenticate with Netlify', { error }); + toast.error('Failed to connect to Netlify'); + updateNetlifyConnection({ user: null, token: '' }); + } finally { + isConnecting.set(false); + } + }; + + const handleDisconnect = () => { + updateNetlifyConnection({ user: null, token: '' }); + toast.success('Disconnected from Netlify'); + }; + + return ( + +
+
+
+ +

Netlify Connection

+
+
+ + {!connection.user ? ( +
+
+ + updateNetlifyConnection({ ...connection, token: e.target.value })} + disabled={connecting} + placeholder="Enter your Netlify personal access 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-[#00AD9F]', + 'disabled:opacity-50', + )} + /> + + + +
+ ) : ( +
+
+
+ + +
+ Connected to Netlify + +
+
+ +
+ {connection.user.full_name} +
+

{connection.user.full_name}

+

{connection.user.email}

+
+
+ + {fetchingStats ? ( +
+
+ Fetching Netlify sites... +
+ ) : ( +
+ + {isSitesExpanded && connection.stats?.sites?.length ? ( +
+ {connection.stats.sites.map((site) => ( + +
+
+
+
+ {site.name} +
+
+ + {site.url} + + {site.published_deploy && ( + <> + + +
+ {new Date(site.published_deploy.published_at).toLocaleDateString()} + + + )} +
+
+ {site.build_settings?.provider && ( +
+ +
+ {site.build_settings.provider} + +
+ )} +
+ + ))} +
+ ) : isSitesExpanded ? ( +
+
+ No sites found in your Netlify account +
+ ) : null} +
+ )} +
+ )} +
+ + ); +} diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index f38a5ccf..12929b10 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -46,6 +46,7 @@ interface BaseChatProps { showChat?: boolean; chatStarted?: boolean; isStreaming?: boolean; + onStreamingChange?: (streaming: boolean) => void; messages?: Message[]; description?: string; enhancingPrompt?: boolean; @@ -81,6 +82,7 @@ export const BaseChat = React.forwardRef( showChat = true, chatStarted = false, isStreaming = false, + onStreamingChange, model, setModel, provider, @@ -129,6 +131,10 @@ export const BaseChat = React.forwardRef( console.log(transcript); }, [transcript]); + useEffect(() => { + onStreamingChange?.(isStreaming); + }, [isStreaming, onStreamingChange]); + useEffect(() => { if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index daec2ad8..8c3346bd 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -24,6 +24,7 @@ import { useSearchParams } from '@remix-run/react'; import { createSampler } from '~/utils/sampler'; import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate'; import { logStore } from '~/lib/stores/logs'; +import { streamingState } from '~/lib/stores/streaming'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', @@ -465,6 +466,9 @@ export const ChatImpl = memo( showChat={showChat} chatStarted={chatStarted} isStreaming={isLoading || fakeLoading} + onStreamingChange={(streaming) => { + streamingState.set(streaming); + }} enhancingPrompt={enhancingPrompt} promptEnhanced={promptEnhanced} sendMessage={sendMessage} diff --git a/app/components/chat/ChatAlert.tsx b/app/components/chat/ChatAlert.tsx index 674bbc80..5aeb08c7 100644 --- a/app/components/chat/ChatAlert.tsx +++ b/app/components/chat/ChatAlert.tsx @@ -24,7 +24,7 @@ export default function ChatAlert({ alert, clearAlert, postMessage }: Props) { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.3 }} - className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4`} + className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2`} >
{/* Icon */} diff --git a/app/components/chat/NetlifyDeploymentLink.client.tsx b/app/components/chat/NetlifyDeploymentLink.client.tsx new file mode 100644 index 00000000..da8e0b41 --- /dev/null +++ b/app/components/chat/NetlifyDeploymentLink.client.tsx @@ -0,0 +1,51 @@ +import { useStore } from '@nanostores/react'; +import { netlifyConnection, fetchNetlifyStats } from '~/lib/stores/netlify'; +import { chatId } from '~/lib/persistence/useChatHistory'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { useEffect } from 'react'; + +export function NetlifyDeploymentLink() { + const connection = useStore(netlifyConnection); + const currentChatId = useStore(chatId); + + useEffect(() => { + if (connection.token && currentChatId) { + fetchNetlifyStats(connection.token); + } + }, [connection.token, currentChatId]); + + const deployedSite = connection.stats?.sites?.find((site) => site.name.includes(`bolt-diy-${currentChatId}`)); + + if (!deployedSite) { + return null; + } + + return ( + + + + { + e.stopPropagation(); // Add this to prevent click from bubbling up + }} + > +
+ + + + + {deployedSite.url} + + + + + + ); +} diff --git a/app/components/chat/ProgressCompilation.tsx b/app/components/chat/ProgressCompilation.tsx index 270fac03..68ae3388 100644 --- a/app/components/chat/ProgressCompilation.tsx +++ b/app/components/chat/ProgressCompilation.tsx @@ -42,7 +42,6 @@ export default function ProgressCompilation({ data }: { data?: ProgressAnnotatio 'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt', 'p-1', )} - style={{ transform: 'translateY(1rem)' }} >
(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> { + const files: Record = {}; + 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( +
+ Deployed successfully!{' '} + + View site + +
, + ); + } catch (error) { + console.error('Deploy error:', error); + toast.error(error instanceof Error ? error.message : 'Deployment failed'); + } finally { + setIsDeploying(false); + } + }; return (
+
+
+ +
+ + {isDropdownOpen && ( +
+ + + +
+ )} +