mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-05-08 14:14:41 +00:00
This commit introduces Vercel integration, enabling users to deploy projects directly to Vercel. It includes: - New Vercel types and store for managing connections and stats. - A VercelConnection component for managing Vercel account connections. - A VercelDeploymentLink component for displaying deployment links. - API routes for handling Vercel deployments. - Updates to the HeaderActionButtons component to support Vercel deployment. The integration allows users to connect their Vercel accounts, view project stats, and deploy projects with ease.
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
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 {
|
|
vercelConnection,
|
|
isConnecting,
|
|
isFetchingStats,
|
|
updateVercelConnection,
|
|
fetchVercelStats,
|
|
} from '~/lib/stores/vercel';
|
|
|
|
export default function VercelConnection() {
|
|
const connection = useStore(vercelConnection);
|
|
const connecting = useStore(isConnecting);
|
|
const fetchingStats = useStore(isFetchingStats);
|
|
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const fetchProjects = async () => {
|
|
if (connection.user && connection.token) {
|
|
await fetchVercelStats(connection.token);
|
|
}
|
|
};
|
|
fetchProjects();
|
|
}, [connection.user, connection.token]);
|
|
|
|
const handleConnect = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
isConnecting.set(true);
|
|
|
|
try {
|
|
const response = await fetch('https://api.vercel.com/v2/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 any;
|
|
updateVercelConnection({
|
|
user: userData.user || userData, // Handle both possible structures
|
|
token: connection.token,
|
|
});
|
|
|
|
await fetchVercelStats(connection.token);
|
|
toast.success('Successfully connected to Vercel');
|
|
} catch (error) {
|
|
console.error('Auth error:', error);
|
|
logStore.logError('Failed to authenticate with Vercel', { error });
|
|
toast.error('Failed to connect to Vercel');
|
|
updateVercelConnection({ user: null, token: '' });
|
|
} finally {
|
|
isConnecting.set(false);
|
|
}
|
|
};
|
|
|
|
const handleDisconnect = () => {
|
|
updateVercelConnection({ user: null, token: '' });
|
|
toast.success('Disconnected from Vercel');
|
|
};
|
|
|
|
console.log('connection', connection);
|
|
|
|
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 dark:invert"
|
|
height="24"
|
|
width="24"
|
|
crossOrigin="anonymous"
|
|
src={`https://cdn.simpleicons.org/vercel/black`}
|
|
/>
|
|
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Vercel 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) => updateVercelConnection({ ...connection, token: e.target.value })}
|
|
disabled={connecting}
|
|
placeholder="Enter your Vercel 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-bolt-elements-borderColorActive',
|
|
'disabled:opacity-50',
|
|
)}
|
|
/>
|
|
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
|
<a
|
|
href="https://vercel.com/account/tokens"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-bolt-elements-borderColorActive 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-bolt-elements-borderColor text-white',
|
|
'hover:bg-bolt-elements-borderColorActive',
|
|
'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 Vercel
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
|
{/* Debug output */}
|
|
<pre className="hidden">{JSON.stringify(connection.user, null, 2)}</pre>
|
|
|
|
<img
|
|
src={`https://vercel.com/api/www/avatar?u=${connection.user?.username || connection.user?.user?.username}`}
|
|
referrerPolicy="no-referrer"
|
|
crossOrigin="anonymous"
|
|
alt="User Avatar"
|
|
className="w-12 h-12 rounded-full border-2 border-bolt-elements-borderColorActive"
|
|
/>
|
|
<div>
|
|
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
|
|
{connection.user?.username || connection.user?.user?.username || 'Vercel User'}
|
|
</h4>
|
|
<p className="text-sm text-bolt-elements-textSecondary">
|
|
{connection.user?.email || connection.user?.user?.email || 'No email available'}
|
|
</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 Vercel projects...
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<button
|
|
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
|
|
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 Projects ({connection.stats?.totalProjects || 0})
|
|
<div
|
|
className={classNames(
|
|
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
|
|
isProjectsExpanded ? 'rotate-180' : '',
|
|
)}
|
|
/>
|
|
</button>
|
|
{isProjectsExpanded && connection.stats?.projects?.length ? (
|
|
<div className="grid gap-3">
|
|
{connection.stats.projects.map((project) => (
|
|
<a
|
|
key={project.id}
|
|
href={`https://vercel.com/dashboard/${project.id}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block p-4 rounded-lg border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive 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-bolt-elements-borderColorActive" />
|
|
{project.name}
|
|
</h5>
|
|
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
|
{project.targets?.production?.alias && project.targets.production.alias.length > 0 ? (
|
|
<>
|
|
<a
|
|
href={`https://${project.targets.production.alias.find((a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="hover:text-bolt-elements-borderColorActive"
|
|
>
|
|
{project.targets.production.alias.find(
|
|
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
|
|
) || project.targets.production.alias[0]}
|
|
</a>
|
|
<span>•</span>
|
|
<span className="flex items-center gap-1">
|
|
<div className="i-ph:clock w-3 h-3" />
|
|
{new Date(project.createdAt).toLocaleDateString()}
|
|
</span>
|
|
</>
|
|
) : project.latestDeployments && project.latestDeployments.length > 0 ? (
|
|
<>
|
|
<a
|
|
href={`https://${project.latestDeployments[0].url}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="hover:text-bolt-elements-borderColorActive"
|
|
>
|
|
{project.latestDeployments[0].url}
|
|
</a>
|
|
<span>•</span>
|
|
<span className="flex items-center gap-1">
|
|
<div className="i-ph:clock w-3 h-3" />
|
|
{new Date(project.latestDeployments[0].created).toLocaleDateString()}
|
|
</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{project.framework && (
|
|
<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:code w-3 h-3" />
|
|
{project.framework}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
) : isProjectsExpanded ? (
|
|
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
|
<div className="i-ph:info w-4 h-4" />
|
|
No projects found in your Vercel account
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|