mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-14 10:19:15 +00:00
Merge branch 'main' into diff-view-v2
This commit is contained in:
commit
a8d8b7b8c7
76
.github/workflows/docker.yaml
vendored
76
.github/workflows/docker.yaml
vendored
@ -1,14 +1,11 @@
|
|||||||
---
|
|
||||||
name: Docker Publish
|
name: Docker Publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
tags:
|
- stable
|
||||||
- v*
|
workflow_dispatch:
|
||||||
- '*'
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
@ -16,66 +13,49 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
DOCKER_IMAGE: ghcr.io/${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
BUILD_TARGET: bolt-ai-production # bolt-ai-development
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build-publish:
|
docker-build-publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }} # ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }}
|
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
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
|
||||||
target: ${{ env.BUILD_TARGET }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
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 }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=registry,ref=${{ steps.string.outputs.lowercase }}:latest
|
|
||||||
cache-to: type=inline
|
|
||||||
|
|
||||||
- name: Check manifest
|
- name: Build and push Docker image for stable
|
||||||
run: |
|
if: github.ref == 'refs/heads/stable'
|
||||||
docker buildx imagetools inspect ${{ steps.string.outputs.lowercase }}:${{ steps.meta.outputs.version }}
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
- name: Dump context
|
context: .
|
||||||
if: always()
|
push: true
|
||||||
uses: crazy-max/ghaction-dump-context@v2
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
@ -6,9 +6,10 @@ WORKDIR /app
|
|||||||
# Install dependencies (this step is cached as long as the dependencies don't change)
|
# Install dependencies (this step is cached as long as the dependencies don't change)
|
||||||
COPY package.json pnpm-lock.yaml ./
|
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 the rest of your app's source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
@ -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 { motion } from 'framer-motion';
|
||||||
import { toast } from 'react-toastify';
|
import { GithubConnection } from './GithubConnection';
|
||||||
|
import { NetlifyConnection } from './NetlifyConnection';
|
||||||
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 default function ConnectionsTab() {
|
export default function ConnectionsTab() {
|
||||||
const [connection, setConnection] = useState<GitHubConnection>({
|
|
||||||
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<Record<string, number>>),
|
|
||||||
);
|
|
||||||
|
|
||||||
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 <LoadingSpinner />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -256,359 +20,8 @@ export default function ConnectionsTab() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{/* GitHub Connection */}
|
<GithubConnection />
|
||||||
<motion.div
|
<NetlifyConnection />
|
||||||
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
>
|
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
|
||||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
|
||||||
<select
|
|
||||||
value={connection.tokenType}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
|
|
||||||
}
|
|
||||||
disabled={isConnecting || !!connection.user}
|
|
||||||
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',
|
|
||||||
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
|
||||||
'disabled:opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<option value="classic">Personal Access Token (Classic)</option>
|
|
||||||
<option value="fine-grained">Fine-grained Token</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
|
||||||
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={connection.token}
|
|
||||||
onChange={(e) => 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',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
|
||||||
<a
|
|
||||||
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
Get your token
|
|
||||||
<div className="i-ph:arrow-square-out w-10 h-5" />
|
|
||||||
</a>
|
|
||||||
<span className="mx-2">•</span>
|
|
||||||
<span>
|
|
||||||
Required scopes:{' '}
|
|
||||||
{connection.tokenType === 'classic'
|
|
||||||
? 'repo, read:org, read:user'
|
|
||||||
: 'Repository access, Organization access'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{!connection.user ? (
|
|
||||||
<button
|
|
||||||
onClick={handleConnect}
|
|
||||||
disabled={isConnecting || !connection.token}
|
|
||||||
className={classNames(
|
|
||||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
|
||||||
'bg-purple-500 text-white',
|
|
||||||
'hover:bg-purple-600',
|
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isConnecting ? (
|
|
||||||
<>
|
|
||||||
<div className="i-ph:spinner-gap animate-spin" />
|
|
||||||
Connecting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="i-ph:plug-charging w-4 h-4" />
|
|
||||||
Connect
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
className={classNames(
|
|
||||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
|
||||||
'bg-red-500 text-white',
|
|
||||||
'hover:bg-red-600',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="i-ph:plug-x w-4 h-4" />
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{connection.user && (
|
|
||||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
|
||||||
<div className="i-ph:check-circle w-4 h-4" />
|
|
||||||
Connected to GitHub
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{connection.user && (
|
|
||||||
<div className="p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<img
|
|
||||||
src={connection.user.avatar_url}
|
|
||||||
alt={connection.user.login}
|
|
||||||
className="w-12 h-12 rounded-full"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.name}</h4>
|
|
||||||
<p className="text-sm text-bolt-elements-textSecondary">@{connection.user.login}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isFetchingStats ? (
|
|
||||||
<div className="mt-4 flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
|
||||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
|
||||||
Fetching GitHub stats...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
connection.stats && (
|
|
||||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-bolt-elements-textSecondary">Public Repos</p>
|
|
||||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
|
||||||
{connection.user.public_repos}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-bolt-elements-textSecondary">Total Stars</p>
|
|
||||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
|
||||||
{connection.stats.totalStars}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-bolt-elements-textSecondary">Total Forks</p>
|
|
||||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
|
||||||
{connection.stats.totalForks}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{connection.user && connection.stats && (
|
|
||||||
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<img
|
|
||||||
src={connection.user.avatar_url}
|
|
||||||
alt={connection.user.login}
|
|
||||||
className="w-16 h-16 rounded-full"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
|
||||||
{connection.user.name || connection.user.login}
|
|
||||||
</h3>
|
|
||||||
{connection.user.bio && (
|
|
||||||
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.bio}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<div className="i-ph:users w-4 h-4" />
|
|
||||||
{connection.user.followers} followers
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<div className="i-ph:star w-4 h-4" />
|
|
||||||
{connection.stats.totalStars} stars
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<div className="i-ph:git-fork w-4 h-4" />
|
|
||||||
{connection.stats.totalForks} forks
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Organizations Section */}
|
|
||||||
{connection.stats.organizations.length > 0 && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{connection.stats.organizations.map((org) => (
|
|
||||||
<a
|
|
||||||
key={org.login}
|
|
||||||
href={org.html_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
|
||||||
>
|
|
||||||
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
|
|
||||||
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Languages Section */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Object.entries(connection.stats.languages)
|
|
||||||
.sort(([, a], [, b]) => b - a)
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(([language]) => (
|
|
||||||
<span
|
|
||||||
key={language}
|
|
||||||
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
|
|
||||||
>
|
|
||||||
{language}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Activity Section */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{connection.stats.recentActivity.map((event) => (
|
|
||||||
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
|
|
||||||
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
|
|
||||||
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
|
|
||||||
<span className="font-medium">{event.type.replace('Event', '')}</span>
|
|
||||||
<span>on</span>
|
|
||||||
<a
|
|
||||||
href={`https://github.com/${event.repo.name}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-purple-500 hover:underline"
|
|
||||||
>
|
|
||||||
{event.repo.name}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
|
|
||||||
{new Date(event.created_at).toLocaleDateString()} at{' '}
|
|
||||||
{new Date(event.created_at).toLocaleTimeString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional Stats */}
|
|
||||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
|
||||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
|
||||||
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
|
|
||||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
|
||||||
{new Date(connection.user.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
|
||||||
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
|
|
||||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
|
||||||
{connection.stats.totalGists}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
|
||||||
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
|
|
||||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
|
||||||
{connection.stats.organizations.length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
|
||||||
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
|
|
||||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
|
||||||
{Object.keys(connection.stats.languages).length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Existing repositories section */}
|
|
||||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{connection.stats.repos.map((repo) => (
|
|
||||||
<a
|
|
||||||
key={repo.full_name}
|
|
||||||
href={repo.html_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
|
||||||
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
|
|
||||||
{repo.name}
|
|
||||||
</h5>
|
|
||||||
{repo.description && (
|
|
||||||
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<div className="i-ph:git-branch w-3 h-3" />
|
|
||||||
{repo.default_branch}
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<div className="i-ph:star w-3 h-3" />
|
|
||||||
{repo.stargazers_count}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<div className="i-ph:git-fork w-3 h-3" />
|
|
||||||
{repo.forks_count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingSpinner() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
|
||||||
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
557
app/components/@settings/tabs/connections/GithubConnection.tsx
Normal file
557
app/components/@settings/tabs/connections/GithubConnection.tsx
Normal file
@ -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<GitHubConnection>({
|
||||||
|
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<Record<string, number>>),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
||||||
|
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
||||||
|
<select
|
||||||
|
value={connection.tokenType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
|
||||||
|
}
|
||||||
|
disabled={isConnecting || !!connection.user}
|
||||||
|
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',
|
||||||
|
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
||||||
|
'disabled:opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="classic">Personal Access Token (Classic)</option>
|
||||||
|
<option value="fine-grained">Fine-grained Token</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
||||||
|
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={connection.token}
|
||||||
|
onChange={(e) => 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',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||||
|
<a
|
||||||
|
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Get your token
|
||||||
|
<div className="i-ph:arrow-square-out w-10 h-5" />
|
||||||
|
</a>
|
||||||
|
<span className="mx-2">•</span>
|
||||||
|
<span>
|
||||||
|
Required scopes:{' '}
|
||||||
|
{connection.tokenType === 'classic'
|
||||||
|
? 'repo, read:org, read:user'
|
||||||
|
: 'Repository access, Organization access'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!connection.user ? (
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={isConnecting || !connection.token}
|
||||||
|
className={classNames(
|
||||||
|
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||||
|
'bg-purple-500 text-white',
|
||||||
|
'hover:bg-purple-600',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<>
|
||||||
|
<div className="i-ph:spinner-gap animate-spin" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="i-ph:plug-charging w-4 h-4" />
|
||||||
|
Connect
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
className={classNames(
|
||||||
|
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||||
|
'bg-red-500 text-white',
|
||||||
|
'hover:bg-red-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="i-ph:plug-x w-4 h-4" />
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connection.user && (
|
||||||
|
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||||
|
<div className="i-ph:check-circle w-4 h-4" />
|
||||||
|
Connected to GitHub
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connection.user && connection.stats && (
|
||||||
|
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
||||||
|
<button onClick={() => setIsStatsExpanded(!isStatsExpanded)} className="w-full bg-transparent">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img src={connection.user.avatar_url} alt={connection.user.login} className="w-16 h-16 rounded-full" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||||
|
{connection.user.name || connection.user.login}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary transition-transform',
|
||||||
|
isStatsExpanded ? 'rotate-180' : '',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{connection.user.bio && (
|
||||||
|
<p className="text-sm text-start text-bolt-elements-textSecondary">{connection.user.bio}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="i-ph:users w-4 h-4" />
|
||||||
|
{connection.user.followers} followers
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="i-ph:book-bookmark w-4 h-4" />
|
||||||
|
{connection.user.public_repos} public repos
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="i-ph:star w-4 h-4" />
|
||||||
|
{connection.stats.totalStars} stars
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="i-ph:git-fork w-4 h-4" />
|
||||||
|
{connection.stats.totalForks} forks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isStatsExpanded && (
|
||||||
|
<div className="pt-4">
|
||||||
|
{connection.stats.organizations.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{connection.stats.organizations.map((org) => (
|
||||||
|
<a
|
||||||
|
key={org.login}
|
||||||
|
href={org.html_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
||||||
|
>
|
||||||
|
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
|
||||||
|
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Languages Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(connection.stats.languages)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([language]) => (
|
||||||
|
<span
|
||||||
|
key={language}
|
||||||
|
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
|
||||||
|
>
|
||||||
|
{language}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{connection.stats.recentActivity.map((event) => (
|
||||||
|
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
|
||||||
|
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
|
||||||
|
<span className="font-medium">{event.type.replace('Event', '')}</span>
|
||||||
|
<span>on</span>
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${event.repo.name}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-purple-500 hover:underline"
|
||||||
|
>
|
||||||
|
{event.repo.name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
|
||||||
|
{new Date(event.created_at).toLocaleDateString()} at{' '}
|
||||||
|
{new Date(event.created_at).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||||
|
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
|
||||||
|
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||||
|
{new Date(connection.user.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||||
|
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
|
||||||
|
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||||
|
{connection.stats.totalGists}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||||
|
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
|
||||||
|
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||||
|
{connection.stats.organizations.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||||
|
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
|
||||||
|
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||||
|
{Object.keys(connection.stats.languages).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Repositories Section */}
|
||||||
|
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{connection.stats.repos.map((repo) => (
|
||||||
|
<a
|
||||||
|
key={repo.full_name}
|
||||||
|
href={repo.html_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
|
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
|
||||||
|
{repo.name}
|
||||||
|
</h5>
|
||||||
|
{repo.description && (
|
||||||
|
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="i-ph:git-branch w-3 h-3" />
|
||||||
|
{repo.default_branch}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="i-ph:star w-3 h-3" />
|
||||||
|
{repo.stargazers_count}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="i-ph:git-fork w-3 h-3" />
|
||||||
|
{repo.forks_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
||||||
|
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
263
app/components/@settings/tabs/connections/NetlifyConnection.tsx
Normal file
263
app/components/@settings/tabs/connections/NetlifyConnection.tsx
Normal file
@ -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 (
|
||||||
|
<motion.div
|
||||||
|
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
className="w-5 h-5"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
src="https://cdn.simpleicons.org/netlify"
|
||||||
|
/>
|
||||||
|
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!connection.user ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={connection.token}
|
||||||
|
onChange={(e) => 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',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||||
|
<a
|
||||||
|
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[#00AD9F] hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Get your token
|
||||||
|
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={connecting || !connection.token}
|
||||||
|
className={classNames(
|
||||||
|
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||||
|
'bg-[#00AD9F] text-white',
|
||||||
|
'hover:bg-[#00968A]',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{connecting ? (
|
||||||
|
<>
|
||||||
|
<div className="i-ph:spinner-gap animate-spin" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="i-ph:plug-charging w-4 h-4" />
|
||||||
|
Connect
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
className={classNames(
|
||||||
|
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||||
|
'bg-red-500 text-white',
|
||||||
|
'hover:bg-red-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="i-ph:plug w-4 h-4" />
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||||
|
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
||||||
|
Connected to Netlify
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
||||||
|
<img
|
||||||
|
src={connection.user.avatar_url}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
alt={connection.user.full_name}
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fetchingStats ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
||||||
|
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||||
|
Fetching Netlify sites...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSitesExpanded(!isSitesExpanded)}
|
||||||
|
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="i-ph:buildings w-4 h-4" />
|
||||||
|
Your Sites ({connection.stats?.totalSites || 0})
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
|
||||||
|
isSitesExpanded ? 'rotate-180' : '',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isSitesExpanded && connection.stats?.sites?.length ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{connection.stats.sites.map((site) => (
|
||||||
|
<a
|
||||||
|
key={site.id}
|
||||||
|
href={site.admin_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
|
<div className="i-ph:globe w-4 h-4 text-[#00AD9F]" />
|
||||||
|
{site.name}
|
||||||
|
</h5>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
||||||
|
<a
|
||||||
|
href={site.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-[#00AD9F]"
|
||||||
|
>
|
||||||
|
{site.url}
|
||||||
|
</a>
|
||||||
|
{site.published_deploy && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="i-ph:clock w-3 h-3" />
|
||||||
|
{new Date(site.published_deploy.published_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{site.build_settings?.provider && (
|
||||||
|
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="i-ph:git-branch w-3 h-3" />
|
||||||
|
{site.build_settings.provider}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : isSitesExpanded ? (
|
||||||
|
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
||||||
|
<div className="i-ph:info w-4 h-4" />
|
||||||
|
No sites found in your Netlify account
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
@ -46,6 +46,7 @@ interface BaseChatProps {
|
|||||||
showChat?: boolean;
|
showChat?: boolean;
|
||||||
chatStarted?: boolean;
|
chatStarted?: boolean;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
onStreamingChange?: (streaming: boolean) => void;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
description?: string;
|
description?: string;
|
||||||
enhancingPrompt?: boolean;
|
enhancingPrompt?: boolean;
|
||||||
@ -81,6 +82,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
showChat = true,
|
showChat = true,
|
||||||
chatStarted = false,
|
chatStarted = false,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
onStreamingChange,
|
||||||
model,
|
model,
|
||||||
setModel,
|
setModel,
|
||||||
provider,
|
provider,
|
||||||
@ -129,6 +131,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
console.log(transcript);
|
console.log(transcript);
|
||||||
}, [transcript]);
|
}, [transcript]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onStreamingChange?.(isStreaming);
|
||||||
|
}, [isStreaming, onStreamingChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
||||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
@ -24,6 +24,7 @@ import { useSearchParams } from '@remix-run/react';
|
|||||||
import { createSampler } from '~/utils/sampler';
|
import { createSampler } from '~/utils/sampler';
|
||||||
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
|
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
|
||||||
import { logStore } from '~/lib/stores/logs';
|
import { logStore } from '~/lib/stores/logs';
|
||||||
|
import { streamingState } from '~/lib/stores/streaming';
|
||||||
|
|
||||||
const toastAnimation = cssTransition({
|
const toastAnimation = cssTransition({
|
||||||
enter: 'animated fadeInRight',
|
enter: 'animated fadeInRight',
|
||||||
@ -465,6 +466,9 @@ export const ChatImpl = memo(
|
|||||||
showChat={showChat}
|
showChat={showChat}
|
||||||
chatStarted={chatStarted}
|
chatStarted={chatStarted}
|
||||||
isStreaming={isLoading || fakeLoading}
|
isStreaming={isLoading || fakeLoading}
|
||||||
|
onStreamingChange={(streaming) => {
|
||||||
|
streamingState.set(streaming);
|
||||||
|
}}
|
||||||
enhancingPrompt={enhancingPrompt}
|
enhancingPrompt={enhancingPrompt}
|
||||||
promptEnhanced={promptEnhanced}
|
promptEnhanced={promptEnhanced}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
|
@ -24,7 +24,7 @@ export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
transition={{ duration: 0.3 }}
|
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`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
|
51
app/components/chat/NetlifyDeploymentLink.client.tsx
Normal file
51
app/components/chat/NetlifyDeploymentLink.client.tsx
Normal file
@ -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 (
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<a
|
||||||
|
href={deployedSite.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#00AD9F] z-50"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Add this to prevent click from bubbling up
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="i-ph:rocket-launch w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content
|
||||||
|
className="px-3 py-2 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary text-xs z-50"
|
||||||
|
sideOffset={5}
|
||||||
|
>
|
||||||
|
{deployedSite.url}
|
||||||
|
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
);
|
||||||
|
}
|
@ -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',
|
'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt',
|
||||||
'p-1',
|
'p-1',
|
||||||
)}
|
)}
|
||||||
style={{ transform: 'translateY(1rem)' }}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
@ -1,21 +1,281 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import useViewport from '~/lib/hooks';
|
import useViewport from '~/lib/hooks';
|
||||||
import { chatStore } from '~/lib/stores/chat';
|
import { chatStore } from '~/lib/stores/chat';
|
||||||
|
import { netlifyConnection } from '~/lib/stores/netlify';
|
||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
import { workbenchStore } from '~/lib/stores/workbench';
|
||||||
|
import { webcontainer } from '~/lib/webcontainer';
|
||||||
import { classNames } from '~/utils/classNames';
|
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 {}
|
interface HeaderActionButtonsProps {}
|
||||||
|
|
||||||
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||||
const { showChat } = useStore(chatStore);
|
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 isSmallViewport = useViewport(1024);
|
||||||
|
|
||||||
const canHideChat = showWorkbench || !showChat;
|
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 (
|
return (
|
||||||
<div className="flex">
|
<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">
|
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
active={showChat}
|
active={showChat}
|
||||||
@ -51,18 +311,23 @@ interface ButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: any;
|
children?: any;
|
||||||
onClick?: VoidFunction;
|
onClick?: VoidFunction;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Button({ active = false, disabled = false, children, onClick }: ButtonProps) {
|
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={classNames('flex items-center p-1.5', {
|
className={classNames(
|
||||||
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
|
'flex items-center p-1.5',
|
||||||
!active,
|
{
|
||||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
|
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
|
||||||
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
!active,
|
||||||
disabled,
|
'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}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -5,6 +5,7 @@ import type { ChatHistoryItem } from './useChatHistory';
|
|||||||
export interface IChatMetadata {
|
export interface IChatMetadata {
|
||||||
gitUrl: string;
|
gitUrl: string;
|
||||||
gitBranch?: string;
|
gitBranch?: string;
|
||||||
|
netlifySiteId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = createScopedLogger('ChatHistory');
|
const logger = createScopedLogger('ChatHistory');
|
||||||
|
@ -70,6 +70,7 @@ export class ActionRunner {
|
|||||||
runnerId = atom<string>(`${Date.now()}`);
|
runnerId = atom<string>(`${Date.now()}`);
|
||||||
actions: ActionsMap = map({});
|
actions: ActionsMap = map({});
|
||||||
onAlert?: (alert: ActionAlert) => void;
|
onAlert?: (alert: ActionAlert) => void;
|
||||||
|
buildOutput?: { path: string; exitCode: number; output: string };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
webcontainerPromise: Promise<WebContainer>,
|
webcontainerPromise: Promise<WebContainer>,
|
||||||
@ -156,6 +157,13 @@ export class ActionRunner {
|
|||||||
await this.#runFileAction(action);
|
await this.#runFileAction(action);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'build': {
|
||||||
|
const buildOutput = await this.#runBuildAction(action);
|
||||||
|
|
||||||
|
// Store build output for deployment
|
||||||
|
this.buildOutput = buildOutput;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'start': {
|
case 'start': {
|
||||||
// making the start app non blocking
|
// making the start app non blocking
|
||||||
|
|
||||||
@ -299,6 +307,7 @@ export class ActionRunner {
|
|||||||
logger.error('Failed to write file\n\n', error);
|
logger.error('Failed to write file\n\n', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateAction(id: string, newState: ActionStateUpdate) {
|
#updateAction(id: string, newState: ActionStateUpdate) {
|
||||||
const actions = this.actions.get();
|
const actions = this.actions.get();
|
||||||
|
|
||||||
@ -331,4 +340,39 @@ export class ActionRunner {
|
|||||||
#getHistoryPath(filePath: string) {
|
#getHistoryPath(filePath: string) {
|
||||||
return nodePath.join('.history', filePath);
|
return nodePath.join('.history', filePath);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
async #runBuildAction(action: ActionState) {
|
||||||
|
if (action.type !== 'build') {
|
||||||
|
unreachable('Expected build action');
|
||||||
|
}
|
||||||
|
|
||||||
|
const webcontainer = await this.#webcontainer;
|
||||||
|
|
||||||
|
// Create a new terminal specifically for the build
|
||||||
|
const buildProcess = await webcontainer.spawn('npm', ['run', 'build']);
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
buildProcess.output.pipeTo(
|
||||||
|
new WritableStream({
|
||||||
|
write(data) {
|
||||||
|
output += data;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitCode = await buildProcess.exit;
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new ActionCommandError('Build Failed', output || 'No Output Available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the build output directory path
|
||||||
|
const buildDir = nodePath.join(webcontainer.workdir, 'dist');
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: buildDir,
|
||||||
|
exitCode,
|
||||||
|
output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
63
app/lib/stores/netlify.ts
Normal file
63
app/lib/stores/netlify.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
import type { NetlifyConnection } from '~/types/netlify';
|
||||||
|
import { logStore } from './logs';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
// Initialize with stored connection or defaults
|
||||||
|
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
|
||||||
|
const initialConnection: NetlifyConnection = storedConnection
|
||||||
|
? JSON.parse(storedConnection)
|
||||||
|
: {
|
||||||
|
user: null,
|
||||||
|
token: '',
|
||||||
|
stats: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const netlifyConnection = atom<NetlifyConnection>(initialConnection);
|
||||||
|
export const isConnecting = atom<boolean>(false);
|
||||||
|
export const isFetchingStats = atom<boolean>(false);
|
||||||
|
|
||||||
|
export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) => {
|
||||||
|
const currentState = netlifyConnection.get();
|
||||||
|
const newState = { ...currentState, ...updates };
|
||||||
|
netlifyConnection.set(newState);
|
||||||
|
|
||||||
|
// Persist to localStorage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('netlify_connection', JSON.stringify(newState));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchNetlifyStats(token: string) {
|
||||||
|
try {
|
||||||
|
isFetchingStats.set(true);
|
||||||
|
|
||||||
|
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sitesResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch sites: ${sitesResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sites = (await sitesResponse.json()) as any;
|
||||||
|
|
||||||
|
const currentState = netlifyConnection.get();
|
||||||
|
updateNetlifyConnection({
|
||||||
|
...currentState,
|
||||||
|
stats: {
|
||||||
|
sites,
|
||||||
|
totalSites: sites.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Netlify API Error:', error);
|
||||||
|
logStore.logError('Failed to fetch Netlify stats', { error });
|
||||||
|
toast.error('Failed to fetch Netlify statistics');
|
||||||
|
} finally {
|
||||||
|
isFetchingStats.set(false);
|
||||||
|
}
|
||||||
|
}
|
3
app/lib/stores/streaming.ts
Normal file
3
app/lib/stores/streaming.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
|
||||||
|
export const streamingState = atom<boolean>(false);
|
229
app/routes/api.deploy.ts
Normal file
229
app/routes/api.deploy.ts
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import type { NetlifySiteInfo } from '~/types/netlify';
|
||||||
|
|
||||||
|
interface DeployRequestBody {
|
||||||
|
siteId?: string;
|
||||||
|
files: Record<string, string>;
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
|
try {
|
||||||
|
const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return json({ error: 'Not connected to Netlify' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetSiteId = siteId;
|
||||||
|
let siteInfo: NetlifySiteInfo | undefined;
|
||||||
|
|
||||||
|
// If no siteId provided, create a new site
|
||||||
|
if (!targetSiteId) {
|
||||||
|
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
|
||||||
|
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: siteName,
|
||||||
|
custom_domain: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createSiteResponse.ok) {
|
||||||
|
return json({ error: 'Failed to create site' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSite = (await createSiteResponse.json()) as any;
|
||||||
|
targetSiteId = newSite.id;
|
||||||
|
siteInfo = {
|
||||||
|
id: newSite.id,
|
||||||
|
name: newSite.name,
|
||||||
|
url: newSite.url,
|
||||||
|
chatId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Get existing site info
|
||||||
|
if (targetSiteId) {
|
||||||
|
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (siteResponse.ok) {
|
||||||
|
const existingSite = (await siteResponse.json()) as any;
|
||||||
|
siteInfo = {
|
||||||
|
id: existingSite.id,
|
||||||
|
name: existingSite.name,
|
||||||
|
url: existingSite.url,
|
||||||
|
chatId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
targetSiteId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no siteId provided or site doesn't exist, create a new site
|
||||||
|
if (!targetSiteId) {
|
||||||
|
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
|
||||||
|
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: siteName,
|
||||||
|
custom_domain: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createSiteResponse.ok) {
|
||||||
|
return json({ error: 'Failed to create site' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSite = (await createSiteResponse.json()) as any;
|
||||||
|
targetSiteId = newSite.id;
|
||||||
|
siteInfo = {
|
||||||
|
id: newSite.id,
|
||||||
|
name: newSite.name,
|
||||||
|
url: newSite.url,
|
||||||
|
chatId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file digests
|
||||||
|
const fileDigests: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [filePath, content] of Object.entries(files)) {
|
||||||
|
// Ensure file path starts with a forward slash
|
||||||
|
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
||||||
|
const hash = crypto.createHash('sha1').update(content).digest('hex');
|
||||||
|
fileDigests[normalizedPath] = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new deploy with digests
|
||||||
|
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
files: fileDigests,
|
||||||
|
async: true,
|
||||||
|
skip_processing: false,
|
||||||
|
draft: false, // Change this to false for production deployments
|
||||||
|
function_schedules: [],
|
||||||
|
required: Object.keys(fileDigests), // Add this line
|
||||||
|
framework: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deployResponse.ok) {
|
||||||
|
return json({ error: 'Failed to create deployment' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deploy = (await deployResponse.json()) as any;
|
||||||
|
let retryCount = 0;
|
||||||
|
const maxRetries = 60;
|
||||||
|
|
||||||
|
// Poll until deploy is ready for file uploads
|
||||||
|
while (retryCount < maxRetries) {
|
||||||
|
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = (await statusResponse.json()) as any;
|
||||||
|
|
||||||
|
if (status.state === 'prepared' || status.state === 'uploaded') {
|
||||||
|
// Upload all files regardless of required array
|
||||||
|
for (const [filePath, content] of Object.entries(files)) {
|
||||||
|
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
||||||
|
|
||||||
|
let uploadSuccess = false;
|
||||||
|
let uploadRetries = 0;
|
||||||
|
|
||||||
|
while (!uploadSuccess && uploadRetries < 3) {
|
||||||
|
try {
|
||||||
|
const uploadResponse = await fetch(
|
||||||
|
`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
body: content,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
uploadSuccess = uploadResponse.ok;
|
||||||
|
|
||||||
|
if (!uploadSuccess) {
|
||||||
|
console.error('Upload failed:', await uploadResponse.text());
|
||||||
|
uploadRetries++;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
uploadRetries++;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uploadSuccess) {
|
||||||
|
return json({ error: `Failed to upload file ${filePath}` }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.state === 'ready') {
|
||||||
|
// Only return after files are uploaded
|
||||||
|
if (Object.keys(files).length === 0 || status.summary?.status === 'ready') {
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
deploy: {
|
||||||
|
id: status.id,
|
||||||
|
state: status.state,
|
||||||
|
url: status.ssl_url || status.url,
|
||||||
|
},
|
||||||
|
site: siteInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.state === 'error') {
|
||||||
|
return json({ error: status.error_message || 'Deploy preparation failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
retryCount++;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryCount >= maxRetries) {
|
||||||
|
return json({ error: 'Deploy preparation timed out' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we're returning the deploy ID and site info
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
deploy: {
|
||||||
|
id: deploy.id,
|
||||||
|
state: deploy.state,
|
||||||
|
},
|
||||||
|
site: siteInfo,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Deploy error:', error);
|
||||||
|
return json({ error: 'Deployment failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
@ -19,7 +19,11 @@ export interface StartAction extends BaseAction {
|
|||||||
type: 'start';
|
type: 'start';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BoltAction = FileAction | ShellAction | StartAction;
|
export interface BuildAction extends BaseAction {
|
||||||
|
type: 'build';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoltAction = FileAction | ShellAction | StartAction | BuildAction;
|
||||||
|
|
||||||
export type BoltActionData = BoltAction | BaseAction;
|
export type BoltActionData = BoltAction | BaseAction;
|
||||||
|
|
||||||
|
41
app/types/netlify.ts
Normal file
41
app/types/netlify.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
export interface NetlifySite {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
admin_url: string;
|
||||||
|
build_settings: {
|
||||||
|
provider: string;
|
||||||
|
repo_url: string;
|
||||||
|
cmd: string;
|
||||||
|
};
|
||||||
|
published_deploy: {
|
||||||
|
published_at: string;
|
||||||
|
deploy_time: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetlifyUser {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
avatar_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetlifyStats {
|
||||||
|
sites: NetlifySite[];
|
||||||
|
totalSites: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetlifyConnection {
|
||||||
|
user: NetlifyUser | null;
|
||||||
|
token: string;
|
||||||
|
stats?: NetlifyStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetlifySiteInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
chatId: string;
|
||||||
|
}
|
@ -98,9 +98,7 @@ const COLOR_PRIMITIVES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
safelist: [
|
safelist: [...Object.keys(customIconCollection[collectionName] || {}).map((x) => `i-bolt:${x}`)],
|
||||||
...Object.keys(customIconCollection[collectionName] || {}).map(x => `i-bolt:${x}`)
|
|
||||||
],
|
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||||
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|
||||||
|
Loading…
Reference in New Issue
Block a user