Merge pull request #1376 from xKevIsDev/main
Browse files- app/components/@settings/tabs/connections/ConnectionsTab.tsx +4 -591
- app/components/@settings/tabs/connections/GithubConnection.tsx +557 -0
- app/components/@settings/tabs/connections/NetlifyConnection.tsx +263 -0
- app/components/chat/BaseChat.tsx +7 -1
- app/components/chat/Chat.client.tsx +4 -0
- app/components/chat/ChatAlert.tsx +1 -1
- app/components/chat/NetlifyDeploymentLink.client.tsx +51 -0
- app/components/chat/ProgressCompilation.tsx +0 -1
- app/components/header/HeaderActionButtons.client.tsx +275 -10
- app/lib/persistence/db.ts +1 -0
- app/lib/runtime/action-runner.ts +43 -0
- app/lib/stores/netlify.ts +63 -0
- app/lib/stores/streaming.ts +3 -0
- app/routes/api.deploy.ts +229 -0
- app/types/actions.ts +5 -1
- app/types/netlify.ts +41 -0
- uno.config.ts +1 -3
app/components/@settings/tabs/connections/ConnectionsTab.tsx
CHANGED
|
@@ -1,244 +1,8 @@
|
|
| 1 |
-
import React, { useState, useEffect } from 'react';
|
| 2 |
-
import { logStore } from '~/lib/stores/logs';
|
| 3 |
-
import { classNames } from '~/utils/classNames';
|
| 4 |
import { motion } from 'framer-motion';
|
| 5 |
-
import {
|
| 6 |
-
|
| 7 |
-
interface GitHubUserResponse {
|
| 8 |
-
login: string;
|
| 9 |
-
avatar_url: string;
|
| 10 |
-
html_url: string;
|
| 11 |
-
name: string;
|
| 12 |
-
bio: string;
|
| 13 |
-
public_repos: number;
|
| 14 |
-
followers: number;
|
| 15 |
-
following: number;
|
| 16 |
-
created_at: string;
|
| 17 |
-
public_gists: number;
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
interface GitHubRepoInfo {
|
| 21 |
-
name: string;
|
| 22 |
-
full_name: string;
|
| 23 |
-
html_url: string;
|
| 24 |
-
description: string;
|
| 25 |
-
stargazers_count: number;
|
| 26 |
-
forks_count: number;
|
| 27 |
-
default_branch: string;
|
| 28 |
-
updated_at: string;
|
| 29 |
-
languages_url: string;
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
interface GitHubOrganization {
|
| 33 |
-
login: string;
|
| 34 |
-
avatar_url: string;
|
| 35 |
-
html_url: string;
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
interface GitHubEvent {
|
| 39 |
-
id: string;
|
| 40 |
-
type: string;
|
| 41 |
-
repo: {
|
| 42 |
-
name: string;
|
| 43 |
-
};
|
| 44 |
-
created_at: string;
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
interface GitHubLanguageStats {
|
| 48 |
-
[language: string]: number;
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
interface GitHubStats {
|
| 52 |
-
repos: GitHubRepoInfo[];
|
| 53 |
-
totalStars: number;
|
| 54 |
-
totalForks: number;
|
| 55 |
-
organizations: GitHubOrganization[];
|
| 56 |
-
recentActivity: GitHubEvent[];
|
| 57 |
-
languages: GitHubLanguageStats;
|
| 58 |
-
totalGists: number;
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
interface GitHubConnection {
|
| 62 |
-
user: GitHubUserResponse | null;
|
| 63 |
-
token: string;
|
| 64 |
-
tokenType: 'classic' | 'fine-grained';
|
| 65 |
-
stats?: GitHubStats;
|
| 66 |
-
}
|
| 67 |
|
| 68 |
export default function ConnectionsTab() {
|
| 69 |
-
const [connection, setConnection] = useState<GitHubConnection>({
|
| 70 |
-
user: null,
|
| 71 |
-
token: '',
|
| 72 |
-
tokenType: 'classic',
|
| 73 |
-
});
|
| 74 |
-
const [isLoading, setIsLoading] = useState(true);
|
| 75 |
-
const [isConnecting, setIsConnecting] = useState(false);
|
| 76 |
-
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
| 77 |
-
|
| 78 |
-
// Load saved connection on mount
|
| 79 |
-
useEffect(() => {
|
| 80 |
-
const savedConnection = localStorage.getItem('github_connection');
|
| 81 |
-
|
| 82 |
-
if (savedConnection) {
|
| 83 |
-
const parsed = JSON.parse(savedConnection);
|
| 84 |
-
|
| 85 |
-
// Ensure backward compatibility with existing connections
|
| 86 |
-
if (!parsed.tokenType) {
|
| 87 |
-
parsed.tokenType = 'classic';
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
setConnection(parsed);
|
| 91 |
-
|
| 92 |
-
if (parsed.user && parsed.token) {
|
| 93 |
-
fetchGitHubStats(parsed.token);
|
| 94 |
-
}
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
setIsLoading(false);
|
| 98 |
-
}, []);
|
| 99 |
-
|
| 100 |
-
const fetchGitHubStats = async (token: string) => {
|
| 101 |
-
try {
|
| 102 |
-
setIsFetchingStats(true);
|
| 103 |
-
|
| 104 |
-
// Fetch repositories - only owned by the authenticated user
|
| 105 |
-
const reposResponse = await fetch(
|
| 106 |
-
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
|
| 107 |
-
{
|
| 108 |
-
headers: {
|
| 109 |
-
Authorization: `Bearer ${token}`,
|
| 110 |
-
},
|
| 111 |
-
},
|
| 112 |
-
);
|
| 113 |
-
|
| 114 |
-
if (!reposResponse.ok) {
|
| 115 |
-
throw new Error('Failed to fetch repositories');
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
const repos = (await reposResponse.json()) as GitHubRepoInfo[];
|
| 119 |
-
|
| 120 |
-
// Fetch organizations
|
| 121 |
-
const orgsResponse = await fetch('https://api.github.com/user/orgs', {
|
| 122 |
-
headers: {
|
| 123 |
-
Authorization: `Bearer ${token}`,
|
| 124 |
-
},
|
| 125 |
-
});
|
| 126 |
-
|
| 127 |
-
if (!orgsResponse.ok) {
|
| 128 |
-
throw new Error('Failed to fetch organizations');
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
const organizations = (await orgsResponse.json()) as GitHubOrganization[];
|
| 132 |
-
|
| 133 |
-
// Fetch recent activity
|
| 134 |
-
const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', {
|
| 135 |
-
headers: {
|
| 136 |
-
Authorization: `Bearer ${token}`,
|
| 137 |
-
},
|
| 138 |
-
});
|
| 139 |
-
|
| 140 |
-
if (!eventsResponse.ok) {
|
| 141 |
-
throw new Error('Failed to fetch events');
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5);
|
| 145 |
-
|
| 146 |
-
// Fetch languages for each repository
|
| 147 |
-
const languagePromises = repos.map((repo) =>
|
| 148 |
-
fetch(repo.languages_url, {
|
| 149 |
-
headers: {
|
| 150 |
-
Authorization: `Bearer ${token}`,
|
| 151 |
-
},
|
| 152 |
-
}).then((res) => res.json() as Promise<Record<string, number>>),
|
| 153 |
-
);
|
| 154 |
-
|
| 155 |
-
const repoLanguages = await Promise.all(languagePromises);
|
| 156 |
-
const languages: GitHubLanguageStats = {};
|
| 157 |
-
|
| 158 |
-
repoLanguages.forEach((repoLang) => {
|
| 159 |
-
Object.entries(repoLang).forEach(([lang, bytes]) => {
|
| 160 |
-
languages[lang] = (languages[lang] || 0) + bytes;
|
| 161 |
-
});
|
| 162 |
-
});
|
| 163 |
-
|
| 164 |
-
// Calculate total stats
|
| 165 |
-
const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0);
|
| 166 |
-
const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0);
|
| 167 |
-
const totalGists = connection.user?.public_gists || 0;
|
| 168 |
-
|
| 169 |
-
setConnection((prev) => ({
|
| 170 |
-
...prev,
|
| 171 |
-
stats: {
|
| 172 |
-
repos,
|
| 173 |
-
totalStars,
|
| 174 |
-
totalForks,
|
| 175 |
-
organizations,
|
| 176 |
-
recentActivity,
|
| 177 |
-
languages,
|
| 178 |
-
totalGists,
|
| 179 |
-
},
|
| 180 |
-
}));
|
| 181 |
-
} catch (error) {
|
| 182 |
-
logStore.logError('Failed to fetch GitHub stats', { error });
|
| 183 |
-
toast.error('Failed to fetch GitHub statistics');
|
| 184 |
-
} finally {
|
| 185 |
-
setIsFetchingStats(false);
|
| 186 |
-
}
|
| 187 |
-
};
|
| 188 |
-
|
| 189 |
-
const fetchGithubUser = async (token: string) => {
|
| 190 |
-
try {
|
| 191 |
-
setIsConnecting(true);
|
| 192 |
-
|
| 193 |
-
const response = await fetch('https://api.github.com/user', {
|
| 194 |
-
headers: {
|
| 195 |
-
Authorization: `Bearer ${token}`,
|
| 196 |
-
},
|
| 197 |
-
});
|
| 198 |
-
|
| 199 |
-
if (!response.ok) {
|
| 200 |
-
throw new Error('Invalid token or unauthorized');
|
| 201 |
-
}
|
| 202 |
-
|
| 203 |
-
const data = (await response.json()) as GitHubUserResponse;
|
| 204 |
-
const newConnection: GitHubConnection = {
|
| 205 |
-
user: data,
|
| 206 |
-
token,
|
| 207 |
-
tokenType: connection.tokenType,
|
| 208 |
-
};
|
| 209 |
-
|
| 210 |
-
// Save connection
|
| 211 |
-
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
| 212 |
-
setConnection(newConnection);
|
| 213 |
-
|
| 214 |
-
// Fetch additional stats
|
| 215 |
-
await fetchGitHubStats(token);
|
| 216 |
-
|
| 217 |
-
toast.success('Successfully connected to GitHub');
|
| 218 |
-
} catch (error) {
|
| 219 |
-
logStore.logError('Failed to authenticate with GitHub', { error });
|
| 220 |
-
toast.error('Failed to connect to GitHub');
|
| 221 |
-
setConnection({ user: null, token: '', tokenType: 'classic' });
|
| 222 |
-
} finally {
|
| 223 |
-
setIsConnecting(false);
|
| 224 |
-
}
|
| 225 |
-
};
|
| 226 |
-
|
| 227 |
-
const handleConnect = async (event: React.FormEvent) => {
|
| 228 |
-
event.preventDefault();
|
| 229 |
-
await fetchGithubUser(connection.token);
|
| 230 |
-
};
|
| 231 |
-
|
| 232 |
-
const handleDisconnect = () => {
|
| 233 |
-
localStorage.removeItem('github_connection');
|
| 234 |
-
setConnection({ user: null, token: '', tokenType: 'classic' });
|
| 235 |
-
toast.success('Disconnected from GitHub');
|
| 236 |
-
};
|
| 237 |
-
|
| 238 |
-
if (isLoading) {
|
| 239 |
-
return <LoadingSpinner />;
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
return (
|
| 243 |
<div className="space-y-4">
|
| 244 |
{/* Header */}
|
|
@@ -256,359 +20,8 @@ export default function ConnectionsTab() {
|
|
| 256 |
</p>
|
| 257 |
|
| 258 |
<div className="grid grid-cols-1 gap-4">
|
| 259 |
-
|
| 260 |
-
<
|
| 261 |
-
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 262 |
-
initial={{ opacity: 0, y: 20 }}
|
| 263 |
-
animate={{ opacity: 1, y: 0 }}
|
| 264 |
-
transition={{ delay: 0.2 }}
|
| 265 |
-
>
|
| 266 |
-
<div className="p-6 space-y-6">
|
| 267 |
-
<div className="flex items-center gap-2">
|
| 268 |
-
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
| 269 |
-
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
| 270 |
-
</div>
|
| 271 |
-
|
| 272 |
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 273 |
-
<div>
|
| 274 |
-
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
| 275 |
-
<select
|
| 276 |
-
value={connection.tokenType}
|
| 277 |
-
onChange={(e) =>
|
| 278 |
-
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
|
| 279 |
-
}
|
| 280 |
-
disabled={isConnecting || !!connection.user}
|
| 281 |
-
className={classNames(
|
| 282 |
-
'w-full px-3 py-2 rounded-lg text-sm',
|
| 283 |
-
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
| 284 |
-
'border border-[#E5E5E5] dark:border-[#333333]',
|
| 285 |
-
'text-bolt-elements-textPrimary',
|
| 286 |
-
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
| 287 |
-
'disabled:opacity-50',
|
| 288 |
-
)}
|
| 289 |
-
>
|
| 290 |
-
<option value="classic">Personal Access Token (Classic)</option>
|
| 291 |
-
<option value="fine-grained">Fine-grained Token</option>
|
| 292 |
-
</select>
|
| 293 |
-
</div>
|
| 294 |
-
|
| 295 |
-
<div>
|
| 296 |
-
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
| 297 |
-
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
| 298 |
-
</label>
|
| 299 |
-
<input
|
| 300 |
-
type="password"
|
| 301 |
-
value={connection.token}
|
| 302 |
-
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
| 303 |
-
disabled={isConnecting || !!connection.user}
|
| 304 |
-
placeholder={`Enter your GitHub ${connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'}`}
|
| 305 |
-
className={classNames(
|
| 306 |
-
'w-full px-3 py-2 rounded-lg text-sm',
|
| 307 |
-
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
| 308 |
-
'border border-[#E5E5E5] dark:border-[#333333]',
|
| 309 |
-
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
| 310 |
-
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
| 311 |
-
'disabled:opacity-50',
|
| 312 |
-
)}
|
| 313 |
-
/>
|
| 314 |
-
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
| 315 |
-
<a
|
| 316 |
-
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
| 317 |
-
target="_blank"
|
| 318 |
-
rel="noopener noreferrer"
|
| 319 |
-
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
| 320 |
-
>
|
| 321 |
-
Get your token
|
| 322 |
-
<div className="i-ph:arrow-square-out w-10 h-5" />
|
| 323 |
-
</a>
|
| 324 |
-
<span className="mx-2">•</span>
|
| 325 |
-
<span>
|
| 326 |
-
Required scopes:{' '}
|
| 327 |
-
{connection.tokenType === 'classic'
|
| 328 |
-
? 'repo, read:org, read:user'
|
| 329 |
-
: 'Repository access, Organization access'}
|
| 330 |
-
</span>
|
| 331 |
-
</div>
|
| 332 |
-
</div>
|
| 333 |
-
</div>
|
| 334 |
-
|
| 335 |
-
<div className="flex items-center gap-3">
|
| 336 |
-
{!connection.user ? (
|
| 337 |
-
<button
|
| 338 |
-
onClick={handleConnect}
|
| 339 |
-
disabled={isConnecting || !connection.token}
|
| 340 |
-
className={classNames(
|
| 341 |
-
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
| 342 |
-
'bg-purple-500 text-white',
|
| 343 |
-
'hover:bg-purple-600',
|
| 344 |
-
'disabled:opacity-50 disabled:cursor-not-allowed',
|
| 345 |
-
)}
|
| 346 |
-
>
|
| 347 |
-
{isConnecting ? (
|
| 348 |
-
<>
|
| 349 |
-
<div className="i-ph:spinner-gap animate-spin" />
|
| 350 |
-
Connecting...
|
| 351 |
-
</>
|
| 352 |
-
) : (
|
| 353 |
-
<>
|
| 354 |
-
<div className="i-ph:plug-charging w-4 h-4" />
|
| 355 |
-
Connect
|
| 356 |
-
</>
|
| 357 |
-
)}
|
| 358 |
-
</button>
|
| 359 |
-
) : (
|
| 360 |
-
<button
|
| 361 |
-
onClick={handleDisconnect}
|
| 362 |
-
className={classNames(
|
| 363 |
-
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
| 364 |
-
'bg-red-500 text-white',
|
| 365 |
-
'hover:bg-red-600',
|
| 366 |
-
)}
|
| 367 |
-
>
|
| 368 |
-
<div className="i-ph:plug-x w-4 h-4" />
|
| 369 |
-
Disconnect
|
| 370 |
-
</button>
|
| 371 |
-
)}
|
| 372 |
-
|
| 373 |
-
{connection.user && (
|
| 374 |
-
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
| 375 |
-
<div className="i-ph:check-circle w-4 h-4" />
|
| 376 |
-
Connected to GitHub
|
| 377 |
-
</span>
|
| 378 |
-
)}
|
| 379 |
-
</div>
|
| 380 |
-
|
| 381 |
-
{connection.user && (
|
| 382 |
-
<div className="p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
| 383 |
-
<div className="flex items-center gap-4">
|
| 384 |
-
<img
|
| 385 |
-
src={connection.user.avatar_url}
|
| 386 |
-
alt={connection.user.login}
|
| 387 |
-
className="w-12 h-12 rounded-full"
|
| 388 |
-
/>
|
| 389 |
-
<div>
|
| 390 |
-
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.name}</h4>
|
| 391 |
-
<p className="text-sm text-bolt-elements-textSecondary">@{connection.user.login}</p>
|
| 392 |
-
</div>
|
| 393 |
-
</div>
|
| 394 |
-
|
| 395 |
-
{isFetchingStats ? (
|
| 396 |
-
<div className="mt-4 flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
| 397 |
-
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
| 398 |
-
Fetching GitHub stats...
|
| 399 |
-
</div>
|
| 400 |
-
) : (
|
| 401 |
-
connection.stats && (
|
| 402 |
-
<div className="mt-4 grid grid-cols-3 gap-4">
|
| 403 |
-
<div>
|
| 404 |
-
<p className="text-sm text-bolt-elements-textSecondary">Public Repos</p>
|
| 405 |
-
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 406 |
-
{connection.user.public_repos}
|
| 407 |
-
</p>
|
| 408 |
-
</div>
|
| 409 |
-
<div>
|
| 410 |
-
<p className="text-sm text-bolt-elements-textSecondary">Total Stars</p>
|
| 411 |
-
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 412 |
-
{connection.stats.totalStars}
|
| 413 |
-
</p>
|
| 414 |
-
</div>
|
| 415 |
-
<div>
|
| 416 |
-
<p className="text-sm text-bolt-elements-textSecondary">Total Forks</p>
|
| 417 |
-
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 418 |
-
{connection.stats.totalForks}
|
| 419 |
-
</p>
|
| 420 |
-
</div>
|
| 421 |
-
</div>
|
| 422 |
-
)
|
| 423 |
-
)}
|
| 424 |
-
</div>
|
| 425 |
-
)}
|
| 426 |
-
|
| 427 |
-
{connection.user && connection.stats && (
|
| 428 |
-
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
| 429 |
-
<div className="flex items-center gap-4 mb-6">
|
| 430 |
-
<img
|
| 431 |
-
src={connection.user.avatar_url}
|
| 432 |
-
alt={connection.user.login}
|
| 433 |
-
className="w-16 h-16 rounded-full"
|
| 434 |
-
/>
|
| 435 |
-
<div>
|
| 436 |
-
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 437 |
-
{connection.user.name || connection.user.login}
|
| 438 |
-
</h3>
|
| 439 |
-
{connection.user.bio && (
|
| 440 |
-
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.bio}</p>
|
| 441 |
-
)}
|
| 442 |
-
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
|
| 443 |
-
<span className="flex items-center gap-1">
|
| 444 |
-
<div className="i-ph:users w-4 h-4" />
|
| 445 |
-
{connection.user.followers} followers
|
| 446 |
-
</span>
|
| 447 |
-
<span className="flex items-center gap-1">
|
| 448 |
-
<div className="i-ph:star w-4 h-4" />
|
| 449 |
-
{connection.stats.totalStars} stars
|
| 450 |
-
</span>
|
| 451 |
-
<span className="flex items-center gap-1">
|
| 452 |
-
<div className="i-ph:git-fork w-4 h-4" />
|
| 453 |
-
{connection.stats.totalForks} forks
|
| 454 |
-
</span>
|
| 455 |
-
</div>
|
| 456 |
-
</div>
|
| 457 |
-
</div>
|
| 458 |
-
|
| 459 |
-
{/* Organizations Section */}
|
| 460 |
-
{connection.stats.organizations.length > 0 && (
|
| 461 |
-
<div className="mb-6">
|
| 462 |
-
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
|
| 463 |
-
<div className="flex flex-wrap gap-3">
|
| 464 |
-
{connection.stats.organizations.map((org) => (
|
| 465 |
-
<a
|
| 466 |
-
key={org.login}
|
| 467 |
-
href={org.html_url}
|
| 468 |
-
target="_blank"
|
| 469 |
-
rel="noopener noreferrer"
|
| 470 |
-
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
| 471 |
-
>
|
| 472 |
-
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
|
| 473 |
-
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
|
| 474 |
-
</a>
|
| 475 |
-
))}
|
| 476 |
-
</div>
|
| 477 |
-
</div>
|
| 478 |
-
)}
|
| 479 |
-
|
| 480 |
-
{/* Languages Section */}
|
| 481 |
-
<div className="mb-6">
|
| 482 |
-
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
| 483 |
-
<div className="flex flex-wrap gap-2">
|
| 484 |
-
{Object.entries(connection.stats.languages)
|
| 485 |
-
.sort(([, a], [, b]) => b - a)
|
| 486 |
-
.slice(0, 5)
|
| 487 |
-
.map(([language]) => (
|
| 488 |
-
<span
|
| 489 |
-
key={language}
|
| 490 |
-
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
|
| 491 |
-
>
|
| 492 |
-
{language}
|
| 493 |
-
</span>
|
| 494 |
-
))}
|
| 495 |
-
</div>
|
| 496 |
-
</div>
|
| 497 |
-
|
| 498 |
-
{/* Recent Activity Section */}
|
| 499 |
-
<div className="mb-6">
|
| 500 |
-
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
|
| 501 |
-
<div className="space-y-3">
|
| 502 |
-
{connection.stats.recentActivity.map((event) => (
|
| 503 |
-
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
|
| 504 |
-
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
|
| 505 |
-
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
|
| 506 |
-
<span className="font-medium">{event.type.replace('Event', '')}</span>
|
| 507 |
-
<span>on</span>
|
| 508 |
-
<a
|
| 509 |
-
href={`https://github.com/${event.repo.name}`}
|
| 510 |
-
target="_blank"
|
| 511 |
-
rel="noopener noreferrer"
|
| 512 |
-
className="text-purple-500 hover:underline"
|
| 513 |
-
>
|
| 514 |
-
{event.repo.name}
|
| 515 |
-
</a>
|
| 516 |
-
</div>
|
| 517 |
-
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
|
| 518 |
-
{new Date(event.created_at).toLocaleDateString()} at{' '}
|
| 519 |
-
{new Date(event.created_at).toLocaleTimeString()}
|
| 520 |
-
</div>
|
| 521 |
-
</div>
|
| 522 |
-
))}
|
| 523 |
-
</div>
|
| 524 |
-
</div>
|
| 525 |
-
|
| 526 |
-
{/* Additional Stats */}
|
| 527 |
-
<div className="grid grid-cols-4 gap-4 mb-6">
|
| 528 |
-
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 529 |
-
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
|
| 530 |
-
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 531 |
-
{new Date(connection.user.created_at).toLocaleDateString()}
|
| 532 |
-
</div>
|
| 533 |
-
</div>
|
| 534 |
-
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 535 |
-
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
|
| 536 |
-
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 537 |
-
{connection.stats.totalGists}
|
| 538 |
-
</div>
|
| 539 |
-
</div>
|
| 540 |
-
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 541 |
-
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
|
| 542 |
-
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 543 |
-
{connection.stats.organizations.length}
|
| 544 |
-
</div>
|
| 545 |
-
</div>
|
| 546 |
-
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 547 |
-
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
|
| 548 |
-
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 549 |
-
{Object.keys(connection.stats.languages).length}
|
| 550 |
-
</div>
|
| 551 |
-
</div>
|
| 552 |
-
</div>
|
| 553 |
-
|
| 554 |
-
{/* Existing repositories section */}
|
| 555 |
-
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
|
| 556 |
-
<div className="space-y-3">
|
| 557 |
-
{connection.stats.repos.map((repo) => (
|
| 558 |
-
<a
|
| 559 |
-
key={repo.full_name}
|
| 560 |
-
href={repo.html_url}
|
| 561 |
-
target="_blank"
|
| 562 |
-
rel="noopener noreferrer"
|
| 563 |
-
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
| 564 |
-
>
|
| 565 |
-
<div className="flex items-center justify-between">
|
| 566 |
-
<div>
|
| 567 |
-
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
| 568 |
-
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
|
| 569 |
-
{repo.name}
|
| 570 |
-
</h5>
|
| 571 |
-
{repo.description && (
|
| 572 |
-
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
|
| 573 |
-
)}
|
| 574 |
-
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
| 575 |
-
<span className="flex items-center gap-1">
|
| 576 |
-
<div className="i-ph:git-branch w-3 h-3" />
|
| 577 |
-
{repo.default_branch}
|
| 578 |
-
</span>
|
| 579 |
-
<span>•</span>
|
| 580 |
-
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
| 581 |
-
</div>
|
| 582 |
-
</div>
|
| 583 |
-
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
| 584 |
-
<span className="flex items-center gap-1">
|
| 585 |
-
<div className="i-ph:star w-3 h-3" />
|
| 586 |
-
{repo.stargazers_count}
|
| 587 |
-
</span>
|
| 588 |
-
<span className="flex items-center gap-1">
|
| 589 |
-
<div className="i-ph:git-fork w-3 h-3" />
|
| 590 |
-
{repo.forks_count}
|
| 591 |
-
</span>
|
| 592 |
-
</div>
|
| 593 |
-
</div>
|
| 594 |
-
</a>
|
| 595 |
-
))}
|
| 596 |
-
</div>
|
| 597 |
-
</div>
|
| 598 |
-
)}
|
| 599 |
-
</div>
|
| 600 |
-
</motion.div>
|
| 601 |
-
</div>
|
| 602 |
-
</div>
|
| 603 |
-
);
|
| 604 |
-
}
|
| 605 |
-
|
| 606 |
-
function LoadingSpinner() {
|
| 607 |
-
return (
|
| 608 |
-
<div className="flex items-center justify-center p-4">
|
| 609 |
-
<div className="flex items-center gap-2">
|
| 610 |
-
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 611 |
-
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
| 612 |
</div>
|
| 613 |
</div>
|
| 614 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { motion } from 'framer-motion';
|
| 2 |
+
import { GithubConnection } from './GithubConnection';
|
| 3 |
+
import { NetlifyConnection } from './NetlifyConnection';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export default function ConnectionsTab() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
return (
|
| 7 |
<div className="space-y-4">
|
| 8 |
{/* Header */}
|
|
|
|
| 20 |
</p>
|
| 21 |
|
| 22 |
<div className="grid grid-cols-1 gap-4">
|
| 23 |
+
<GithubConnection />
|
| 24 |
+
<NetlifyConnection />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
</div>
|
| 26 |
</div>
|
| 27 |
);
|
app/components/@settings/tabs/connections/GithubConnection.tsx
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { toast } from 'react-toastify';
|
| 4 |
+
import { logStore } from '~/lib/stores/logs';
|
| 5 |
+
import { classNames } from '~/utils/classNames';
|
| 6 |
+
|
| 7 |
+
interface GitHubUserResponse {
|
| 8 |
+
login: string;
|
| 9 |
+
avatar_url: string;
|
| 10 |
+
html_url: string;
|
| 11 |
+
name: string;
|
| 12 |
+
bio: string;
|
| 13 |
+
public_repos: number;
|
| 14 |
+
followers: number;
|
| 15 |
+
following: number;
|
| 16 |
+
created_at: string;
|
| 17 |
+
public_gists: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface GitHubRepoInfo {
|
| 21 |
+
name: string;
|
| 22 |
+
full_name: string;
|
| 23 |
+
html_url: string;
|
| 24 |
+
description: string;
|
| 25 |
+
stargazers_count: number;
|
| 26 |
+
forks_count: number;
|
| 27 |
+
default_branch: string;
|
| 28 |
+
updated_at: string;
|
| 29 |
+
languages_url: string;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
interface GitHubOrganization {
|
| 33 |
+
login: string;
|
| 34 |
+
avatar_url: string;
|
| 35 |
+
html_url: string;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface GitHubEvent {
|
| 39 |
+
id: string;
|
| 40 |
+
type: string;
|
| 41 |
+
repo: {
|
| 42 |
+
name: string;
|
| 43 |
+
};
|
| 44 |
+
created_at: string;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
interface GitHubLanguageStats {
|
| 48 |
+
[language: string]: number;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
interface GitHubStats {
|
| 52 |
+
repos: GitHubRepoInfo[];
|
| 53 |
+
totalStars: number;
|
| 54 |
+
totalForks: number;
|
| 55 |
+
organizations: GitHubOrganization[];
|
| 56 |
+
recentActivity: GitHubEvent[];
|
| 57 |
+
languages: GitHubLanguageStats;
|
| 58 |
+
totalGists: number;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
interface GitHubConnection {
|
| 62 |
+
user: GitHubUserResponse | null;
|
| 63 |
+
token: string;
|
| 64 |
+
tokenType: 'classic' | 'fine-grained';
|
| 65 |
+
stats?: GitHubStats;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function GithubConnection() {
|
| 69 |
+
const [connection, setConnection] = useState<GitHubConnection>({
|
| 70 |
+
user: null,
|
| 71 |
+
token: '',
|
| 72 |
+
tokenType: 'classic',
|
| 73 |
+
});
|
| 74 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 75 |
+
const [isConnecting, setIsConnecting] = useState(false);
|
| 76 |
+
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
| 77 |
+
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
|
| 78 |
+
|
| 79 |
+
const fetchGitHubStats = async (token: string) => {
|
| 80 |
+
try {
|
| 81 |
+
setIsFetchingStats(true);
|
| 82 |
+
|
| 83 |
+
const reposResponse = await fetch(
|
| 84 |
+
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
|
| 85 |
+
{
|
| 86 |
+
headers: {
|
| 87 |
+
Authorization: `Bearer ${token}`,
|
| 88 |
+
},
|
| 89 |
+
},
|
| 90 |
+
);
|
| 91 |
+
|
| 92 |
+
if (!reposResponse.ok) {
|
| 93 |
+
throw new Error('Failed to fetch repositories');
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const repos = (await reposResponse.json()) as GitHubRepoInfo[];
|
| 97 |
+
|
| 98 |
+
const orgsResponse = await fetch('https://api.github.com/user/orgs', {
|
| 99 |
+
headers: {
|
| 100 |
+
Authorization: `Bearer ${token}`,
|
| 101 |
+
},
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
if (!orgsResponse.ok) {
|
| 105 |
+
throw new Error('Failed to fetch organizations');
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const organizations = (await orgsResponse.json()) as GitHubOrganization[];
|
| 109 |
+
|
| 110 |
+
const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', {
|
| 111 |
+
headers: {
|
| 112 |
+
Authorization: `Bearer ${token}`,
|
| 113 |
+
},
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
if (!eventsResponse.ok) {
|
| 117 |
+
throw new Error('Failed to fetch events');
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5);
|
| 121 |
+
|
| 122 |
+
const languagePromises = repos.map((repo) =>
|
| 123 |
+
fetch(repo.languages_url, {
|
| 124 |
+
headers: {
|
| 125 |
+
Authorization: `Bearer ${token}`,
|
| 126 |
+
},
|
| 127 |
+
}).then((res) => res.json() as Promise<Record<string, number>>),
|
| 128 |
+
);
|
| 129 |
+
|
| 130 |
+
const repoLanguages = await Promise.all(languagePromises);
|
| 131 |
+
const languages: GitHubLanguageStats = {};
|
| 132 |
+
|
| 133 |
+
repoLanguages.forEach((repoLang) => {
|
| 134 |
+
Object.entries(repoLang).forEach(([lang, bytes]) => {
|
| 135 |
+
languages[lang] = (languages[lang] || 0) + bytes;
|
| 136 |
+
});
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0);
|
| 140 |
+
const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0);
|
| 141 |
+
const totalGists = connection.user?.public_gists || 0;
|
| 142 |
+
|
| 143 |
+
setConnection((prev) => ({
|
| 144 |
+
...prev,
|
| 145 |
+
stats: {
|
| 146 |
+
repos,
|
| 147 |
+
totalStars,
|
| 148 |
+
totalForks,
|
| 149 |
+
organizations,
|
| 150 |
+
recentActivity,
|
| 151 |
+
languages,
|
| 152 |
+
totalGists,
|
| 153 |
+
},
|
| 154 |
+
}));
|
| 155 |
+
} catch (error) {
|
| 156 |
+
logStore.logError('Failed to fetch GitHub stats', { error });
|
| 157 |
+
toast.error('Failed to fetch GitHub statistics');
|
| 158 |
+
} finally {
|
| 159 |
+
setIsFetchingStats(false);
|
| 160 |
+
}
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
useEffect(() => {
|
| 164 |
+
const savedConnection = localStorage.getItem('github_connection');
|
| 165 |
+
|
| 166 |
+
if (savedConnection) {
|
| 167 |
+
const parsed = JSON.parse(savedConnection);
|
| 168 |
+
|
| 169 |
+
if (!parsed.tokenType) {
|
| 170 |
+
parsed.tokenType = 'classic';
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
setConnection(parsed);
|
| 174 |
+
|
| 175 |
+
if (parsed.user && parsed.token) {
|
| 176 |
+
fetchGitHubStats(parsed.token);
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
setIsLoading(false);
|
| 181 |
+
}, []);
|
| 182 |
+
|
| 183 |
+
if (isLoading || isConnecting || isFetchingStats) {
|
| 184 |
+
return <LoadingSpinner />;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
const fetchGithubUser = async (token: string) => {
|
| 188 |
+
try {
|
| 189 |
+
setIsConnecting(true);
|
| 190 |
+
|
| 191 |
+
const response = await fetch('https://api.github.com/user', {
|
| 192 |
+
headers: {
|
| 193 |
+
Authorization: `Bearer ${token}`,
|
| 194 |
+
},
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
if (!response.ok) {
|
| 198 |
+
throw new Error('Invalid token or unauthorized');
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
const data = (await response.json()) as GitHubUserResponse;
|
| 202 |
+
const newConnection: GitHubConnection = {
|
| 203 |
+
user: data,
|
| 204 |
+
token,
|
| 205 |
+
tokenType: connection.tokenType,
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
| 209 |
+
setConnection(newConnection);
|
| 210 |
+
|
| 211 |
+
await fetchGitHubStats(token);
|
| 212 |
+
|
| 213 |
+
toast.success('Successfully connected to GitHub');
|
| 214 |
+
} catch (error) {
|
| 215 |
+
logStore.logError('Failed to authenticate with GitHub', { error });
|
| 216 |
+
toast.error('Failed to connect to GitHub');
|
| 217 |
+
setConnection({ user: null, token: '', tokenType: 'classic' });
|
| 218 |
+
} finally {
|
| 219 |
+
setIsConnecting(false);
|
| 220 |
+
}
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
const handleConnect = async (event: React.FormEvent) => {
|
| 224 |
+
event.preventDefault();
|
| 225 |
+
await fetchGithubUser(connection.token);
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
const handleDisconnect = () => {
|
| 229 |
+
localStorage.removeItem('github_connection');
|
| 230 |
+
setConnection({ user: null, token: '', tokenType: 'classic' });
|
| 231 |
+
toast.success('Disconnected from GitHub');
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
return (
|
| 235 |
+
<motion.div
|
| 236 |
+
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 237 |
+
initial={{ opacity: 0, y: 20 }}
|
| 238 |
+
animate={{ opacity: 1, y: 0 }}
|
| 239 |
+
transition={{ delay: 0.2 }}
|
| 240 |
+
>
|
| 241 |
+
<div className="p-6 space-y-6">
|
| 242 |
+
<div className="flex items-center gap-2">
|
| 243 |
+
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
| 244 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 248 |
+
<div>
|
| 249 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
| 250 |
+
<select
|
| 251 |
+
value={connection.tokenType}
|
| 252 |
+
onChange={(e) =>
|
| 253 |
+
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
|
| 254 |
+
}
|
| 255 |
+
disabled={isConnecting || !!connection.user}
|
| 256 |
+
className={classNames(
|
| 257 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
| 258 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
| 259 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
| 260 |
+
'text-bolt-elements-textPrimary',
|
| 261 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
| 262 |
+
'disabled:opacity-50',
|
| 263 |
+
)}
|
| 264 |
+
>
|
| 265 |
+
<option value="classic">Personal Access Token (Classic)</option>
|
| 266 |
+
<option value="fine-grained">Fine-grained Token</option>
|
| 267 |
+
</select>
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
<div>
|
| 271 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
| 272 |
+
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
| 273 |
+
</label>
|
| 274 |
+
<input
|
| 275 |
+
type="password"
|
| 276 |
+
value={connection.token}
|
| 277 |
+
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
| 278 |
+
disabled={isConnecting || !!connection.user}
|
| 279 |
+
placeholder={`Enter your GitHub ${
|
| 280 |
+
connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
|
| 281 |
+
}`}
|
| 282 |
+
className={classNames(
|
| 283 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
| 284 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
| 285 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
| 286 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
| 287 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
| 288 |
+
'disabled:opacity-50',
|
| 289 |
+
)}
|
| 290 |
+
/>
|
| 291 |
+
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
| 292 |
+
<a
|
| 293 |
+
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
| 294 |
+
target="_blank"
|
| 295 |
+
rel="noopener noreferrer"
|
| 296 |
+
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
| 297 |
+
>
|
| 298 |
+
Get your token
|
| 299 |
+
<div className="i-ph:arrow-square-out w-10 h-5" />
|
| 300 |
+
</a>
|
| 301 |
+
<span className="mx-2">•</span>
|
| 302 |
+
<span>
|
| 303 |
+
Required scopes:{' '}
|
| 304 |
+
{connection.tokenType === 'classic'
|
| 305 |
+
? 'repo, read:org, read:user'
|
| 306 |
+
: 'Repository access, Organization access'}
|
| 307 |
+
</span>
|
| 308 |
+
</div>
|
| 309 |
+
</div>
|
| 310 |
+
</div>
|
| 311 |
+
|
| 312 |
+
<div className="flex items-center gap-3">
|
| 313 |
+
{!connection.user ? (
|
| 314 |
+
<button
|
| 315 |
+
onClick={handleConnect}
|
| 316 |
+
disabled={isConnecting || !connection.token}
|
| 317 |
+
className={classNames(
|
| 318 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
| 319 |
+
'bg-purple-500 text-white',
|
| 320 |
+
'hover:bg-purple-600',
|
| 321 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
| 322 |
+
)}
|
| 323 |
+
>
|
| 324 |
+
{isConnecting ? (
|
| 325 |
+
<>
|
| 326 |
+
<div className="i-ph:spinner-gap animate-spin" />
|
| 327 |
+
Connecting...
|
| 328 |
+
</>
|
| 329 |
+
) : (
|
| 330 |
+
<>
|
| 331 |
+
<div className="i-ph:plug-charging w-4 h-4" />
|
| 332 |
+
Connect
|
| 333 |
+
</>
|
| 334 |
+
)}
|
| 335 |
+
</button>
|
| 336 |
+
) : (
|
| 337 |
+
<button
|
| 338 |
+
onClick={handleDisconnect}
|
| 339 |
+
className={classNames(
|
| 340 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
| 341 |
+
'bg-red-500 text-white',
|
| 342 |
+
'hover:bg-red-600',
|
| 343 |
+
)}
|
| 344 |
+
>
|
| 345 |
+
<div className="i-ph:plug-x w-4 h-4" />
|
| 346 |
+
Disconnect
|
| 347 |
+
</button>
|
| 348 |
+
)}
|
| 349 |
+
|
| 350 |
+
{connection.user && (
|
| 351 |
+
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
| 352 |
+
<div className="i-ph:check-circle w-4 h-4" />
|
| 353 |
+
Connected to GitHub
|
| 354 |
+
</span>
|
| 355 |
+
)}
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
{connection.user && connection.stats && (
|
| 359 |
+
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
| 360 |
+
<button onClick={() => setIsStatsExpanded(!isStatsExpanded)} className="w-full bg-transparent">
|
| 361 |
+
<div className="flex items-center gap-4">
|
| 362 |
+
<img src={connection.user.avatar_url} alt={connection.user.login} className="w-16 h-16 rounded-full" />
|
| 363 |
+
<div className="flex-1">
|
| 364 |
+
<div className="flex items-center justify-between">
|
| 365 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 366 |
+
{connection.user.name || connection.user.login}
|
| 367 |
+
</h3>
|
| 368 |
+
<div
|
| 369 |
+
className={classNames(
|
| 370 |
+
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary transition-transform',
|
| 371 |
+
isStatsExpanded ? 'rotate-180' : '',
|
| 372 |
+
)}
|
| 373 |
+
/>
|
| 374 |
+
</div>
|
| 375 |
+
{connection.user.bio && (
|
| 376 |
+
<p className="text-sm text-start text-bolt-elements-textSecondary">{connection.user.bio}</p>
|
| 377 |
+
)}
|
| 378 |
+
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
|
| 379 |
+
<span className="flex items-center gap-1">
|
| 380 |
+
<div className="i-ph:users w-4 h-4" />
|
| 381 |
+
{connection.user.followers} followers
|
| 382 |
+
</span>
|
| 383 |
+
<span className="flex items-center gap-1">
|
| 384 |
+
<div className="i-ph:book-bookmark w-4 h-4" />
|
| 385 |
+
{connection.user.public_repos} public repos
|
| 386 |
+
</span>
|
| 387 |
+
<span className="flex items-center gap-1">
|
| 388 |
+
<div className="i-ph:star w-4 h-4" />
|
| 389 |
+
{connection.stats.totalStars} stars
|
| 390 |
+
</span>
|
| 391 |
+
<span className="flex items-center gap-1">
|
| 392 |
+
<div className="i-ph:git-fork w-4 h-4" />
|
| 393 |
+
{connection.stats.totalForks} forks
|
| 394 |
+
</span>
|
| 395 |
+
</div>
|
| 396 |
+
</div>
|
| 397 |
+
</div>
|
| 398 |
+
</button>
|
| 399 |
+
|
| 400 |
+
{isStatsExpanded && (
|
| 401 |
+
<div className="pt-4">
|
| 402 |
+
{connection.stats.organizations.length > 0 && (
|
| 403 |
+
<div className="mb-6">
|
| 404 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
|
| 405 |
+
<div className="flex flex-wrap gap-3">
|
| 406 |
+
{connection.stats.organizations.map((org) => (
|
| 407 |
+
<a
|
| 408 |
+
key={org.login}
|
| 409 |
+
href={org.html_url}
|
| 410 |
+
target="_blank"
|
| 411 |
+
rel="noopener noreferrer"
|
| 412 |
+
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
| 413 |
+
>
|
| 414 |
+
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
|
| 415 |
+
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
|
| 416 |
+
</a>
|
| 417 |
+
))}
|
| 418 |
+
</div>
|
| 419 |
+
</div>
|
| 420 |
+
)}
|
| 421 |
+
|
| 422 |
+
{/* Languages Section */}
|
| 423 |
+
<div className="mb-6">
|
| 424 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
| 425 |
+
<div className="flex flex-wrap gap-2">
|
| 426 |
+
{Object.entries(connection.stats.languages)
|
| 427 |
+
.sort(([, a], [, b]) => b - a)
|
| 428 |
+
.slice(0, 5)
|
| 429 |
+
.map(([language]) => (
|
| 430 |
+
<span
|
| 431 |
+
key={language}
|
| 432 |
+
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
|
| 433 |
+
>
|
| 434 |
+
{language}
|
| 435 |
+
</span>
|
| 436 |
+
))}
|
| 437 |
+
</div>
|
| 438 |
+
</div>
|
| 439 |
+
|
| 440 |
+
{/* Recent Activity Section */}
|
| 441 |
+
<div className="mb-6">
|
| 442 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
|
| 443 |
+
<div className="space-y-3">
|
| 444 |
+
{connection.stats.recentActivity.map((event) => (
|
| 445 |
+
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
|
| 446 |
+
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
|
| 447 |
+
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
|
| 448 |
+
<span className="font-medium">{event.type.replace('Event', '')}</span>
|
| 449 |
+
<span>on</span>
|
| 450 |
+
<a
|
| 451 |
+
href={`https://github.com/${event.repo.name}`}
|
| 452 |
+
target="_blank"
|
| 453 |
+
rel="noopener noreferrer"
|
| 454 |
+
className="text-purple-500 hover:underline"
|
| 455 |
+
>
|
| 456 |
+
{event.repo.name}
|
| 457 |
+
</a>
|
| 458 |
+
</div>
|
| 459 |
+
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
|
| 460 |
+
{new Date(event.created_at).toLocaleDateString()} at{' '}
|
| 461 |
+
{new Date(event.created_at).toLocaleTimeString()}
|
| 462 |
+
</div>
|
| 463 |
+
</div>
|
| 464 |
+
))}
|
| 465 |
+
</div>
|
| 466 |
+
</div>
|
| 467 |
+
|
| 468 |
+
{/* Additional Stats */}
|
| 469 |
+
<div className="grid grid-cols-4 gap-4 mb-6">
|
| 470 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 471 |
+
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
|
| 472 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 473 |
+
{new Date(connection.user.created_at).toLocaleDateString()}
|
| 474 |
+
</div>
|
| 475 |
+
</div>
|
| 476 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 477 |
+
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
|
| 478 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 479 |
+
{connection.stats.totalGists}
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 483 |
+
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
|
| 484 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 485 |
+
{connection.stats.organizations.length}
|
| 486 |
+
</div>
|
| 487 |
+
</div>
|
| 488 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 489 |
+
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
|
| 490 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 491 |
+
{Object.keys(connection.stats.languages).length}
|
| 492 |
+
</div>
|
| 493 |
+
</div>
|
| 494 |
+
</div>
|
| 495 |
+
|
| 496 |
+
{/* Repositories Section */}
|
| 497 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
|
| 498 |
+
<div className="space-y-3">
|
| 499 |
+
{connection.stats.repos.map((repo) => (
|
| 500 |
+
<a
|
| 501 |
+
key={repo.full_name}
|
| 502 |
+
href={repo.html_url}
|
| 503 |
+
target="_blank"
|
| 504 |
+
rel="noopener noreferrer"
|
| 505 |
+
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
| 506 |
+
>
|
| 507 |
+
<div className="flex items-center justify-between">
|
| 508 |
+
<div>
|
| 509 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
| 510 |
+
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
|
| 511 |
+
{repo.name}
|
| 512 |
+
</h5>
|
| 513 |
+
{repo.description && (
|
| 514 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
|
| 515 |
+
)}
|
| 516 |
+
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
| 517 |
+
<span className="flex items-center gap-1">
|
| 518 |
+
<div className="i-ph:git-branch w-3 h-3" />
|
| 519 |
+
{repo.default_branch}
|
| 520 |
+
</span>
|
| 521 |
+
<span>•</span>
|
| 522 |
+
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
| 523 |
+
</div>
|
| 524 |
+
</div>
|
| 525 |
+
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
| 526 |
+
<span className="flex items-center gap-1">
|
| 527 |
+
<div className="i-ph:star w-3 h-3" />
|
| 528 |
+
{repo.stargazers_count}
|
| 529 |
+
</span>
|
| 530 |
+
<span className="flex items-center gap-1">
|
| 531 |
+
<div className="i-ph:git-fork w-3 h-3" />
|
| 532 |
+
{repo.forks_count}
|
| 533 |
+
</span>
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
</a>
|
| 537 |
+
))}
|
| 538 |
+
</div>
|
| 539 |
+
</div>
|
| 540 |
+
)}
|
| 541 |
+
</div>
|
| 542 |
+
)}
|
| 543 |
+
</div>
|
| 544 |
+
</motion.div>
|
| 545 |
+
);
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
function LoadingSpinner() {
|
| 549 |
+
return (
|
| 550 |
+
<div className="flex items-center justify-center p-4">
|
| 551 |
+
<div className="flex items-center gap-2">
|
| 552 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 553 |
+
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
| 554 |
+
</div>
|
| 555 |
+
</div>
|
| 556 |
+
);
|
| 557 |
+
}
|
app/components/@settings/tabs/connections/NetlifyConnection.tsx
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { toast } from 'react-toastify';
|
| 4 |
+
import { useStore } from '@nanostores/react';
|
| 5 |
+
import { logStore } from '~/lib/stores/logs';
|
| 6 |
+
import { classNames } from '~/utils/classNames';
|
| 7 |
+
import {
|
| 8 |
+
netlifyConnection,
|
| 9 |
+
isConnecting,
|
| 10 |
+
isFetchingStats,
|
| 11 |
+
updateNetlifyConnection,
|
| 12 |
+
fetchNetlifyStats,
|
| 13 |
+
} from '~/lib/stores/netlify';
|
| 14 |
+
import type { NetlifyUser } from '~/types/netlify';
|
| 15 |
+
|
| 16 |
+
export function NetlifyConnection() {
|
| 17 |
+
const connection = useStore(netlifyConnection);
|
| 18 |
+
const connecting = useStore(isConnecting);
|
| 19 |
+
const fetchingStats = useStore(isFetchingStats);
|
| 20 |
+
const [isSitesExpanded, setIsSitesExpanded] = useState(false);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
const fetchSites = async () => {
|
| 24 |
+
if (connection.user && connection.token) {
|
| 25 |
+
await fetchNetlifyStats(connection.token);
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
fetchSites();
|
| 29 |
+
}, [connection.user, connection.token]);
|
| 30 |
+
|
| 31 |
+
const handleConnect = async (event: React.FormEvent) => {
|
| 32 |
+
event.preventDefault();
|
| 33 |
+
isConnecting.set(true);
|
| 34 |
+
|
| 35 |
+
try {
|
| 36 |
+
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
| 37 |
+
headers: {
|
| 38 |
+
Authorization: `Bearer ${connection.token}`,
|
| 39 |
+
'Content-Type': 'application/json',
|
| 40 |
+
},
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
if (!response.ok) {
|
| 44 |
+
throw new Error('Invalid token or unauthorized');
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const userData = (await response.json()) as NetlifyUser;
|
| 48 |
+
updateNetlifyConnection({
|
| 49 |
+
user: userData,
|
| 50 |
+
token: connection.token,
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
await fetchNetlifyStats(connection.token);
|
| 54 |
+
toast.success('Successfully connected to Netlify');
|
| 55 |
+
} catch (error) {
|
| 56 |
+
console.error('Auth error:', error);
|
| 57 |
+
logStore.logError('Failed to authenticate with Netlify', { error });
|
| 58 |
+
toast.error('Failed to connect to Netlify');
|
| 59 |
+
updateNetlifyConnection({ user: null, token: '' });
|
| 60 |
+
} finally {
|
| 61 |
+
isConnecting.set(false);
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const handleDisconnect = () => {
|
| 66 |
+
updateNetlifyConnection({ user: null, token: '' });
|
| 67 |
+
toast.success('Disconnected from Netlify');
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<motion.div
|
| 72 |
+
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 73 |
+
initial={{ opacity: 0, y: 20 }}
|
| 74 |
+
animate={{ opacity: 1, y: 0 }}
|
| 75 |
+
transition={{ delay: 0.3 }}
|
| 76 |
+
>
|
| 77 |
+
<div className="p-6 space-y-6">
|
| 78 |
+
<div className="flex items-center justify-between">
|
| 79 |
+
<div className="flex items-center gap-2">
|
| 80 |
+
<img
|
| 81 |
+
className="w-5 h-5"
|
| 82 |
+
height="24"
|
| 83 |
+
width="24"
|
| 84 |
+
crossOrigin="anonymous"
|
| 85 |
+
src="https://cdn.simpleicons.org/netlify"
|
| 86 |
+
/>
|
| 87 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{!connection.user ? (
|
| 92 |
+
<div className="space-y-4">
|
| 93 |
+
<div>
|
| 94 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
|
| 95 |
+
<input
|
| 96 |
+
type="password"
|
| 97 |
+
value={connection.token}
|
| 98 |
+
onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })}
|
| 99 |
+
disabled={connecting}
|
| 100 |
+
placeholder="Enter your Netlify personal access token"
|
| 101 |
+
className={classNames(
|
| 102 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
| 103 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
| 104 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
| 105 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
| 106 |
+
'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]',
|
| 107 |
+
'disabled:opacity-50',
|
| 108 |
+
)}
|
| 109 |
+
/>
|
| 110 |
+
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
| 111 |
+
<a
|
| 112 |
+
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
| 113 |
+
target="_blank"
|
| 114 |
+
rel="noopener noreferrer"
|
| 115 |
+
className="text-[#00AD9F] hover:underline inline-flex items-center gap-1"
|
| 116 |
+
>
|
| 117 |
+
Get your token
|
| 118 |
+
<div className="i-ph:arrow-square-out w-4 h-4" />
|
| 119 |
+
</a>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<button
|
| 124 |
+
onClick={handleConnect}
|
| 125 |
+
disabled={connecting || !connection.token}
|
| 126 |
+
className={classNames(
|
| 127 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
| 128 |
+
'bg-[#00AD9F] text-white',
|
| 129 |
+
'hover:bg-[#00968A]',
|
| 130 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
| 131 |
+
)}
|
| 132 |
+
>
|
| 133 |
+
{connecting ? (
|
| 134 |
+
<>
|
| 135 |
+
<div className="i-ph:spinner-gap animate-spin" />
|
| 136 |
+
Connecting...
|
| 137 |
+
</>
|
| 138 |
+
) : (
|
| 139 |
+
<>
|
| 140 |
+
<div className="i-ph:plug-charging w-4 h-4" />
|
| 141 |
+
Connect
|
| 142 |
+
</>
|
| 143 |
+
)}
|
| 144 |
+
</button>
|
| 145 |
+
</div>
|
| 146 |
+
) : (
|
| 147 |
+
<div className="space-y-6">
|
| 148 |
+
<div className="flex items-center justify-between">
|
| 149 |
+
<div className="flex items-center gap-3">
|
| 150 |
+
<button
|
| 151 |
+
onClick={handleDisconnect}
|
| 152 |
+
className={classNames(
|
| 153 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
| 154 |
+
'bg-red-500 text-white',
|
| 155 |
+
'hover:bg-red-600',
|
| 156 |
+
)}
|
| 157 |
+
>
|
| 158 |
+
<div className="i-ph:plug w-4 h-4" />
|
| 159 |
+
Disconnect
|
| 160 |
+
</button>
|
| 161 |
+
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
| 162 |
+
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
| 163 |
+
Connected to Netlify
|
| 164 |
+
</span>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
| 169 |
+
<img
|
| 170 |
+
src={connection.user.avatar_url}
|
| 171 |
+
referrerPolicy="no-referrer"
|
| 172 |
+
crossOrigin="anonymous"
|
| 173 |
+
alt={connection.user.full_name}
|
| 174 |
+
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
|
| 175 |
+
/>
|
| 176 |
+
<div>
|
| 177 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
|
| 178 |
+
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
{fetchingStats ? (
|
| 183 |
+
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
| 184 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
| 185 |
+
Fetching Netlify sites...
|
| 186 |
+
</div>
|
| 187 |
+
) : (
|
| 188 |
+
<div>
|
| 189 |
+
<button
|
| 190 |
+
onClick={() => setIsSitesExpanded(!isSitesExpanded)}
|
| 191 |
+
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
|
| 192 |
+
>
|
| 193 |
+
<div className="i-ph:buildings w-4 h-4" />
|
| 194 |
+
Your Sites ({connection.stats?.totalSites || 0})
|
| 195 |
+
<div
|
| 196 |
+
className={classNames(
|
| 197 |
+
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
|
| 198 |
+
isSitesExpanded ? 'rotate-180' : '',
|
| 199 |
+
)}
|
| 200 |
+
/>
|
| 201 |
+
</button>
|
| 202 |
+
{isSitesExpanded && connection.stats?.sites?.length ? (
|
| 203 |
+
<div className="grid gap-3">
|
| 204 |
+
{connection.stats.sites.map((site) => (
|
| 205 |
+
<a
|
| 206 |
+
key={site.id}
|
| 207 |
+
href={site.admin_url}
|
| 208 |
+
target="_blank"
|
| 209 |
+
rel="noopener noreferrer"
|
| 210 |
+
className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] transition-colors"
|
| 211 |
+
>
|
| 212 |
+
<div className="flex items-center justify-between">
|
| 213 |
+
<div>
|
| 214 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
| 215 |
+
<div className="i-ph:globe w-4 h-4 text-[#00AD9F]" />
|
| 216 |
+
{site.name}
|
| 217 |
+
</h5>
|
| 218 |
+
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
| 219 |
+
<a
|
| 220 |
+
href={site.url}
|
| 221 |
+
target="_blank"
|
| 222 |
+
rel="noopener noreferrer"
|
| 223 |
+
className="hover:text-[#00AD9F]"
|
| 224 |
+
>
|
| 225 |
+
{site.url}
|
| 226 |
+
</a>
|
| 227 |
+
{site.published_deploy && (
|
| 228 |
+
<>
|
| 229 |
+
<span>•</span>
|
| 230 |
+
<span className="flex items-center gap-1">
|
| 231 |
+
<div className="i-ph:clock w-3 h-3" />
|
| 232 |
+
{new Date(site.published_deploy.published_at).toLocaleDateString()}
|
| 233 |
+
</span>
|
| 234 |
+
</>
|
| 235 |
+
)}
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
{site.build_settings?.provider && (
|
| 239 |
+
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
|
| 240 |
+
<span className="flex items-center gap-1">
|
| 241 |
+
<div className="i-ph:git-branch w-3 h-3" />
|
| 242 |
+
{site.build_settings.provider}
|
| 243 |
+
</span>
|
| 244 |
+
</div>
|
| 245 |
+
)}
|
| 246 |
+
</div>
|
| 247 |
+
</a>
|
| 248 |
+
))}
|
| 249 |
+
</div>
|
| 250 |
+
) : isSitesExpanded ? (
|
| 251 |
+
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
| 252 |
+
<div className="i-ph:info w-4 h-4" />
|
| 253 |
+
No sites found in your Netlify account
|
| 254 |
+
</div>
|
| 255 |
+
) : null}
|
| 256 |
+
</div>
|
| 257 |
+
)}
|
| 258 |
+
</div>
|
| 259 |
+
)}
|
| 260 |
+
</div>
|
| 261 |
+
</motion.div>
|
| 262 |
+
);
|
| 263 |
+
}
|
app/components/chat/BaseChat.tsx
CHANGED
|
@@ -45,6 +45,7 @@ interface BaseChatProps {
|
|
| 45 |
showChat?: boolean;
|
| 46 |
chatStarted?: boolean;
|
| 47 |
isStreaming?: boolean;
|
|
|
|
| 48 |
messages?: Message[];
|
| 49 |
description?: string;
|
| 50 |
enhancingPrompt?: boolean;
|
|
@@ -79,6 +80,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
| 79 |
showChat = true,
|
| 80 |
chatStarted = false,
|
| 81 |
isStreaming = false,
|
|
|
|
| 82 |
model,
|
| 83 |
setModel,
|
| 84 |
provider,
|
|
@@ -126,6 +128,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
| 126 |
console.log(transcript);
|
| 127 |
}, [transcript]);
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
useEffect(() => {
|
| 130 |
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
| 131 |
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
@@ -337,7 +343,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
| 337 |
}}
|
| 338 |
</ClientOnly>
|
| 339 |
<div
|
| 340 |
-
className={classNames('flex flex-col
|
| 341 |
'sticky bottom-2': chatStarted,
|
| 342 |
'position-absolute': chatStarted,
|
| 343 |
})}
|
|
|
|
| 45 |
showChat?: boolean;
|
| 46 |
chatStarted?: boolean;
|
| 47 |
isStreaming?: boolean;
|
| 48 |
+
onStreamingChange?: (streaming: boolean) => void;
|
| 49 |
messages?: Message[];
|
| 50 |
description?: string;
|
| 51 |
enhancingPrompt?: boolean;
|
|
|
|
| 80 |
showChat = true,
|
| 81 |
chatStarted = false,
|
| 82 |
isStreaming = false,
|
| 83 |
+
onStreamingChange,
|
| 84 |
model,
|
| 85 |
setModel,
|
| 86 |
provider,
|
|
|
|
| 128 |
console.log(transcript);
|
| 129 |
}, [transcript]);
|
| 130 |
|
| 131 |
+
useEffect(() => {
|
| 132 |
+
onStreamingChange?.(isStreaming);
|
| 133 |
+
}, [isStreaming, onStreamingChange]);
|
| 134 |
+
|
| 135 |
useEffect(() => {
|
| 136 |
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
| 137 |
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
|
|
| 343 |
}}
|
| 344 |
</ClientOnly>
|
| 345 |
<div
|
| 346 |
+
className={classNames('flex flex-col w-full max-w-chat mx-auto z-prompt', {
|
| 347 |
'sticky bottom-2': chatStarted,
|
| 348 |
'position-absolute': chatStarted,
|
| 349 |
})}
|
app/components/chat/Chat.client.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import { useSearchParams } from '@remix-run/react';
|
|
| 24 |
import { createSampler } from '~/utils/sampler';
|
| 25 |
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
|
| 26 |
import { logStore } from '~/lib/stores/logs';
|
|
|
|
| 27 |
|
| 28 |
const toastAnimation = cssTransition({
|
| 29 |
enter: 'animated fadeInRight',
|
|
@@ -465,6 +466,9 @@ export const ChatImpl = memo(
|
|
| 465 |
showChat={showChat}
|
| 466 |
chatStarted={chatStarted}
|
| 467 |
isStreaming={isLoading || fakeLoading}
|
|
|
|
|
|
|
|
|
|
| 468 |
enhancingPrompt={enhancingPrompt}
|
| 469 |
promptEnhanced={promptEnhanced}
|
| 470 |
sendMessage={sendMessage}
|
|
|
|
| 24 |
import { createSampler } from '~/utils/sampler';
|
| 25 |
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
|
| 26 |
import { logStore } from '~/lib/stores/logs';
|
| 27 |
+
import { streamingState } from '~/lib/stores/streaming';
|
| 28 |
|
| 29 |
const toastAnimation = cssTransition({
|
| 30 |
enter: 'animated fadeInRight',
|
|
|
|
| 466 |
showChat={showChat}
|
| 467 |
chatStarted={chatStarted}
|
| 468 |
isStreaming={isLoading || fakeLoading}
|
| 469 |
+
onStreamingChange={(streaming) => {
|
| 470 |
+
streamingState.set(streaming);
|
| 471 |
+
}}
|
| 472 |
enhancingPrompt={enhancingPrompt}
|
| 473 |
promptEnhanced={promptEnhanced}
|
| 474 |
sendMessage={sendMessage}
|
app/components/chat/ChatAlert.tsx
CHANGED
|
@@ -24,7 +24,7 @@ export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
|
|
| 24 |
animate={{ opacity: 1, y: 0 }}
|
| 25 |
exit={{ opacity: 0, y: -20 }}
|
| 26 |
transition={{ duration: 0.3 }}
|
| 27 |
-
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4`}
|
| 28 |
>
|
| 29 |
<div className="flex items-start">
|
| 30 |
{/* Icon */}
|
|
|
|
| 24 |
animate={{ opacity: 1, y: 0 }}
|
| 25 |
exit={{ opacity: 0, y: -20 }}
|
| 26 |
transition={{ duration: 0.3 }}
|
| 27 |
+
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2`}
|
| 28 |
>
|
| 29 |
<div className="flex items-start">
|
| 30 |
{/* Icon */}
|
app/components/chat/NetlifyDeploymentLink.client.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useStore } from '@nanostores/react';
|
| 2 |
+
import { netlifyConnection, fetchNetlifyStats } from '~/lib/stores/netlify';
|
| 3 |
+
import { chatId } from '~/lib/persistence/useChatHistory';
|
| 4 |
+
import * as Tooltip from '@radix-ui/react-tooltip';
|
| 5 |
+
import { useEffect } from 'react';
|
| 6 |
+
|
| 7 |
+
export function NetlifyDeploymentLink() {
|
| 8 |
+
const connection = useStore(netlifyConnection);
|
| 9 |
+
const currentChatId = useStore(chatId);
|
| 10 |
+
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
if (connection.token && currentChatId) {
|
| 13 |
+
fetchNetlifyStats(connection.token);
|
| 14 |
+
}
|
| 15 |
+
}, [connection.token, currentChatId]);
|
| 16 |
+
|
| 17 |
+
const deployedSite = connection.stats?.sites?.find((site) => site.name.includes(`bolt-diy-${currentChatId}`));
|
| 18 |
+
|
| 19 |
+
if (!deployedSite) {
|
| 20 |
+
return null;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<Tooltip.Provider>
|
| 25 |
+
<Tooltip.Root>
|
| 26 |
+
<Tooltip.Trigger asChild>
|
| 27 |
+
<a
|
| 28 |
+
href={deployedSite.url}
|
| 29 |
+
target="_blank"
|
| 30 |
+
rel="noopener noreferrer"
|
| 31 |
+
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"
|
| 32 |
+
onClick={(e) => {
|
| 33 |
+
e.stopPropagation(); // Add this to prevent click from bubbling up
|
| 34 |
+
}}
|
| 35 |
+
>
|
| 36 |
+
<div className="i-ph:rocket-launch w-5 h-5" />
|
| 37 |
+
</a>
|
| 38 |
+
</Tooltip.Trigger>
|
| 39 |
+
<Tooltip.Portal>
|
| 40 |
+
<Tooltip.Content
|
| 41 |
+
className="px-3 py-2 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary text-xs z-50"
|
| 42 |
+
sideOffset={5}
|
| 43 |
+
>
|
| 44 |
+
{deployedSite.url}
|
| 45 |
+
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
|
| 46 |
+
</Tooltip.Content>
|
| 47 |
+
</Tooltip.Portal>
|
| 48 |
+
</Tooltip.Root>
|
| 49 |
+
</Tooltip.Provider>
|
| 50 |
+
);
|
| 51 |
+
}
|
app/components/chat/ProgressCompilation.tsx
CHANGED
|
@@ -42,7 +42,6 @@ export default function ProgressCompilation({ data }: { data?: ProgressAnnotatio
|
|
| 42 |
'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt',
|
| 43 |
'p-1',
|
| 44 |
)}
|
| 45 |
-
style={{ transform: 'translateY(1rem)' }}
|
| 46 |
>
|
| 47 |
<div
|
| 48 |
className={classNames(
|
|
|
|
| 42 |
'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt',
|
| 43 |
'p-1',
|
| 44 |
)}
|
|
|
|
| 45 |
>
|
| 46 |
<div
|
| 47 |
className={classNames(
|
app/components/header/HeaderActionButtons.client.tsx
CHANGED
|
@@ -1,21 +1,281 @@
|
|
| 1 |
import { useStore } from '@nanostores/react';
|
|
|
|
| 2 |
import useViewport from '~/lib/hooks';
|
| 3 |
import { chatStore } from '~/lib/stores/chat';
|
|
|
|
| 4 |
import { workbenchStore } from '~/lib/stores/workbench';
|
|
|
|
| 5 |
import { classNames } from '~/utils/classNames';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
interface HeaderActionButtonsProps {}
|
| 8 |
|
| 9 |
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
| 10 |
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
| 11 |
const { showChat } = useStore(chatStore);
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
const isSmallViewport = useViewport(1024);
|
| 14 |
-
|
| 15 |
const canHideChat = showWorkbench || !showChat;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
return (
|
| 18 |
<div className="flex">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
| 20 |
<Button
|
| 21 |
active={showChat}
|
|
@@ -51,18 +311,23 @@ interface ButtonProps {
|
|
| 51 |
disabled?: boolean;
|
| 52 |
children?: any;
|
| 53 |
onClick?: VoidFunction;
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
-
function Button({ active = false, disabled = false, children, onClick }: ButtonProps) {
|
| 57 |
return (
|
| 58 |
<button
|
| 59 |
-
className={classNames(
|
| 60 |
-
'
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
disabled,
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
onClick={onClick}
|
| 67 |
>
|
| 68 |
{children}
|
|
|
|
| 1 |
import { useStore } from '@nanostores/react';
|
| 2 |
+
import { toast } from 'react-toastify';
|
| 3 |
import useViewport from '~/lib/hooks';
|
| 4 |
import { chatStore } from '~/lib/stores/chat';
|
| 5 |
+
import { netlifyConnection } from '~/lib/stores/netlify';
|
| 6 |
import { workbenchStore } from '~/lib/stores/workbench';
|
| 7 |
+
import { webcontainer } from '~/lib/webcontainer';
|
| 8 |
import { classNames } from '~/utils/classNames';
|
| 9 |
+
import { path } from '~/utils/path';
|
| 10 |
+
import { useEffect, useRef, useState } from 'react';
|
| 11 |
+
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
|
| 12 |
+
import { chatId } from '~/lib/persistence/useChatHistory'; // Add this import
|
| 13 |
+
import { streamingState } from '~/lib/stores/streaming';
|
| 14 |
+
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
|
| 15 |
|
| 16 |
interface HeaderActionButtonsProps {}
|
| 17 |
|
| 18 |
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
| 19 |
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
| 20 |
const { showChat } = useStore(chatStore);
|
| 21 |
+
const connection = useStore(netlifyConnection);
|
| 22 |
+
const [activePreviewIndex] = useState(0);
|
| 23 |
+
const previews = useStore(workbenchStore.previews);
|
| 24 |
+
const activePreview = previews[activePreviewIndex];
|
| 25 |
+
const [isDeploying, setIsDeploying] = useState(false);
|
| 26 |
const isSmallViewport = useViewport(1024);
|
|
|
|
| 27 |
const canHideChat = showWorkbench || !showChat;
|
| 28 |
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
| 29 |
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
| 30 |
+
const isStreaming = useStore(streamingState);
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
function handleClickOutside(event: MouseEvent) {
|
| 34 |
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
| 35 |
+
setIsDropdownOpen(false);
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 39 |
+
|
| 40 |
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 41 |
+
}, []);
|
| 42 |
+
|
| 43 |
+
const currentChatId = useStore(chatId);
|
| 44 |
+
|
| 45 |
+
const handleDeploy = async () => {
|
| 46 |
+
if (!connection.user || !connection.token) {
|
| 47 |
+
toast.error('Please connect to Netlify first in the settings tab!');
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if (!currentChatId) {
|
| 52 |
+
toast.error('No active chat found');
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
try {
|
| 57 |
+
setIsDeploying(true);
|
| 58 |
+
|
| 59 |
+
const artifact = workbenchStore.firstArtifact;
|
| 60 |
+
|
| 61 |
+
if (!artifact) {
|
| 62 |
+
throw new Error('No active project found');
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const actionId = 'build-' + Date.now();
|
| 66 |
+
const actionData: ActionCallbackData = {
|
| 67 |
+
messageId: 'netlify build',
|
| 68 |
+
artifactId: artifact.id,
|
| 69 |
+
actionId,
|
| 70 |
+
action: {
|
| 71 |
+
type: 'build' as const,
|
| 72 |
+
content: 'npm run build',
|
| 73 |
+
},
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
// Add the action first
|
| 77 |
+
artifact.runner.addAction(actionData);
|
| 78 |
+
|
| 79 |
+
// Then run it
|
| 80 |
+
await artifact.runner.runAction(actionData);
|
| 81 |
+
|
| 82 |
+
if (!artifact.runner.buildOutput) {
|
| 83 |
+
throw new Error('Build failed');
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Get the build files
|
| 87 |
+
const container = await webcontainer;
|
| 88 |
+
|
| 89 |
+
// Remove /home/project from buildPath if it exists
|
| 90 |
+
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
| 91 |
+
|
| 92 |
+
// Get all files recursively
|
| 93 |
+
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
|
| 94 |
+
const files: Record<string, string> = {};
|
| 95 |
+
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
|
| 96 |
+
|
| 97 |
+
for (const entry of entries) {
|
| 98 |
+
const fullPath = path.join(dirPath, entry.name);
|
| 99 |
+
|
| 100 |
+
if (entry.isFile()) {
|
| 101 |
+
const content = await container.fs.readFile(fullPath, 'utf-8');
|
| 102 |
+
|
| 103 |
+
// Remove /dist prefix from the path
|
| 104 |
+
const deployPath = fullPath.replace(buildPath, '');
|
| 105 |
+
files[deployPath] = content;
|
| 106 |
+
} else if (entry.isDirectory()) {
|
| 107 |
+
const subFiles = await getAllFiles(fullPath);
|
| 108 |
+
Object.assign(files, subFiles);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return files;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const fileContents = await getAllFiles(buildPath);
|
| 116 |
+
|
| 117 |
+
// Use chatId instead of artifact.id
|
| 118 |
+
const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
|
| 119 |
+
|
| 120 |
+
// Deploy using the API route with file contents
|
| 121 |
+
const response = await fetch('/api/deploy', {
|
| 122 |
+
method: 'POST',
|
| 123 |
+
headers: {
|
| 124 |
+
'Content-Type': 'application/json',
|
| 125 |
+
},
|
| 126 |
+
body: JSON.stringify({
|
| 127 |
+
siteId: existingSiteId || undefined,
|
| 128 |
+
files: fileContents,
|
| 129 |
+
token: connection.token,
|
| 130 |
+
chatId: currentChatId, // Use chatId instead of artifact.id
|
| 131 |
+
}),
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
const data = (await response.json()) as any;
|
| 135 |
+
|
| 136 |
+
if (!response.ok || !data.deploy || !data.site) {
|
| 137 |
+
console.error('Invalid deploy response:', data);
|
| 138 |
+
throw new Error(data.error || 'Invalid deployment response');
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// Poll for deployment status
|
| 142 |
+
const maxAttempts = 20; // 2 minutes timeout
|
| 143 |
+
let attempts = 0;
|
| 144 |
+
let deploymentStatus;
|
| 145 |
+
|
| 146 |
+
while (attempts < maxAttempts) {
|
| 147 |
+
try {
|
| 148 |
+
const statusResponse = await fetch(
|
| 149 |
+
`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
|
| 150 |
+
{
|
| 151 |
+
headers: {
|
| 152 |
+
Authorization: `Bearer ${connection.token}`,
|
| 153 |
+
},
|
| 154 |
+
},
|
| 155 |
+
);
|
| 156 |
+
|
| 157 |
+
deploymentStatus = (await statusResponse.json()) as any;
|
| 158 |
+
|
| 159 |
+
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
|
| 160 |
+
break;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if (deploymentStatus.state === 'error') {
|
| 164 |
+
throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
attempts++;
|
| 168 |
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
| 169 |
+
} catch (error) {
|
| 170 |
+
console.error('Status check error:', error);
|
| 171 |
+
attempts++;
|
| 172 |
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
if (attempts >= maxAttempts) {
|
| 177 |
+
throw new Error('Deployment timed out');
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// Store the site ID if it's a new site
|
| 181 |
+
if (data.site) {
|
| 182 |
+
localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
toast.success(
|
| 186 |
+
<div>
|
| 187 |
+
Deployed successfully!{' '}
|
| 188 |
+
<a
|
| 189 |
+
href={deploymentStatus.ssl_url || deploymentStatus.url}
|
| 190 |
+
target="_blank"
|
| 191 |
+
rel="noopener noreferrer"
|
| 192 |
+
className="underline"
|
| 193 |
+
>
|
| 194 |
+
View site
|
| 195 |
+
</a>
|
| 196 |
+
</div>,
|
| 197 |
+
);
|
| 198 |
+
} catch (error) {
|
| 199 |
+
console.error('Deploy error:', error);
|
| 200 |
+
toast.error(error instanceof Error ? error.message : 'Deployment failed');
|
| 201 |
+
} finally {
|
| 202 |
+
setIsDeploying(false);
|
| 203 |
+
}
|
| 204 |
+
};
|
| 205 |
|
| 206 |
return (
|
| 207 |
<div className="flex">
|
| 208 |
+
<div className="relative" ref={dropdownRef}>
|
| 209 |
+
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
|
| 210 |
+
<Button
|
| 211 |
+
active
|
| 212 |
+
disabled={isDeploying || !activePreview || isStreaming}
|
| 213 |
+
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
| 214 |
+
className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2"
|
| 215 |
+
>
|
| 216 |
+
{isDeploying ? 'Deploying...' : 'Deploy'}
|
| 217 |
+
<div
|
| 218 |
+
className={classNames('i-ph:caret-down w-4 h-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
|
| 219 |
+
/>
|
| 220 |
+
</Button>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
{isDropdownOpen && (
|
| 224 |
+
<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">
|
| 225 |
+
<Button
|
| 226 |
+
active
|
| 227 |
+
onClick={() => {
|
| 228 |
+
handleDeploy();
|
| 229 |
+
setIsDropdownOpen(false);
|
| 230 |
+
}}
|
| 231 |
+
disabled={isDeploying || !activePreview || !connection.user}
|
| 232 |
+
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"
|
| 233 |
+
>
|
| 234 |
+
<img
|
| 235 |
+
className="w-5 h-5"
|
| 236 |
+
height="24"
|
| 237 |
+
width="24"
|
| 238 |
+
crossOrigin="anonymous"
|
| 239 |
+
src="https://cdn.simpleicons.org/netlify"
|
| 240 |
+
/>
|
| 241 |
+
<span className="mx-auto">{!connection.user ? 'No Account Connected' : 'Deploy to Netlify'}</span>
|
| 242 |
+
{connection.user && <NetlifyDeploymentLink />}
|
| 243 |
+
</Button>
|
| 244 |
+
<Button
|
| 245 |
+
active={false}
|
| 246 |
+
disabled
|
| 247 |
+
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2"
|
| 248 |
+
>
|
| 249 |
+
<span className="sr-only">Coming Soon</span>
|
| 250 |
+
<img
|
| 251 |
+
className="w-5 h-5 bg-black p-1 rounded"
|
| 252 |
+
height="24"
|
| 253 |
+
width="24"
|
| 254 |
+
crossOrigin="anonymous"
|
| 255 |
+
src="https://cdn.simpleicons.org/vercel/white"
|
| 256 |
+
alt="vercel"
|
| 257 |
+
/>
|
| 258 |
+
<span className="mx-auto">Deploy to Vercel (Coming Soon)</span>
|
| 259 |
+
</Button>
|
| 260 |
+
<Button
|
| 261 |
+
active={false}
|
| 262 |
+
disabled
|
| 263 |
+
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2"
|
| 264 |
+
>
|
| 265 |
+
<span className="sr-only">Coming Soon</span>
|
| 266 |
+
<img
|
| 267 |
+
className="w-5 h-5"
|
| 268 |
+
height="24"
|
| 269 |
+
width="24"
|
| 270 |
+
crossOrigin="anonymous"
|
| 271 |
+
src="https://cdn.simpleicons.org/cloudflare"
|
| 272 |
+
alt="vercel"
|
| 273 |
+
/>
|
| 274 |
+
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
|
| 275 |
+
</Button>
|
| 276 |
+
</div>
|
| 277 |
+
)}
|
| 278 |
+
</div>
|
| 279 |
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
| 280 |
<Button
|
| 281 |
active={showChat}
|
|
|
|
| 311 |
disabled?: boolean;
|
| 312 |
children?: any;
|
| 313 |
onClick?: VoidFunction;
|
| 314 |
+
className?: string;
|
| 315 |
}
|
| 316 |
|
| 317 |
+
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
| 318 |
return (
|
| 319 |
<button
|
| 320 |
+
className={classNames(
|
| 321 |
+
'flex items-center p-1.5',
|
| 322 |
+
{
|
| 323 |
+
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
|
| 324 |
+
!active,
|
| 325 |
+
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
|
| 326 |
+
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
| 327 |
+
disabled,
|
| 328 |
+
},
|
| 329 |
+
className,
|
| 330 |
+
)}
|
| 331 |
onClick={onClick}
|
| 332 |
>
|
| 333 |
{children}
|
app/lib/persistence/db.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { ChatHistoryItem } from './useChatHistory';
|
|
| 5 |
export interface IChatMetadata {
|
| 6 |
gitUrl: string;
|
| 7 |
gitBranch?: string;
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
const logger = createScopedLogger('ChatHistory');
|
|
|
|
| 5 |
export interface IChatMetadata {
|
| 6 |
gitUrl: string;
|
| 7 |
gitBranch?: string;
|
| 8 |
+
netlifySiteId?: string;
|
| 9 |
}
|
| 10 |
|
| 11 |
const logger = createScopedLogger('ChatHistory');
|
app/lib/runtime/action-runner.ts
CHANGED
|
@@ -70,6 +70,7 @@ export class ActionRunner {
|
|
| 70 |
runnerId = atom<string>(`${Date.now()}`);
|
| 71 |
actions: ActionsMap = map({});
|
| 72 |
onAlert?: (alert: ActionAlert) => void;
|
|
|
|
| 73 |
|
| 74 |
constructor(
|
| 75 |
webcontainerPromise: Promise<WebContainer>,
|
|
@@ -156,6 +157,13 @@ export class ActionRunner {
|
|
| 156 |
await this.#runFileAction(action);
|
| 157 |
break;
|
| 158 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
case 'start': {
|
| 160 |
// making the start app non blocking
|
| 161 |
|
|
@@ -304,4 +312,39 @@ export class ActionRunner {
|
|
| 304 |
|
| 305 |
this.actions.setKey(id, { ...actions[id], ...newState });
|
| 306 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
}
|
|
|
|
| 70 |
runnerId = atom<string>(`${Date.now()}`);
|
| 71 |
actions: ActionsMap = map({});
|
| 72 |
onAlert?: (alert: ActionAlert) => void;
|
| 73 |
+
buildOutput?: { path: string; exitCode: number; output: string };
|
| 74 |
|
| 75 |
constructor(
|
| 76 |
webcontainerPromise: Promise<WebContainer>,
|
|
|
|
| 157 |
await this.#runFileAction(action);
|
| 158 |
break;
|
| 159 |
}
|
| 160 |
+
case 'build': {
|
| 161 |
+
const buildOutput = await this.#runBuildAction(action);
|
| 162 |
+
|
| 163 |
+
// Store build output for deployment
|
| 164 |
+
this.buildOutput = buildOutput;
|
| 165 |
+
break;
|
| 166 |
+
}
|
| 167 |
case 'start': {
|
| 168 |
// making the start app non blocking
|
| 169 |
|
|
|
|
| 312 |
|
| 313 |
this.actions.setKey(id, { ...actions[id], ...newState });
|
| 314 |
}
|
| 315 |
+
|
| 316 |
+
async #runBuildAction(action: ActionState) {
|
| 317 |
+
if (action.type !== 'build') {
|
| 318 |
+
unreachable('Expected build action');
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
const webcontainer = await this.#webcontainer;
|
| 322 |
+
|
| 323 |
+
// Create a new terminal specifically for the build
|
| 324 |
+
const buildProcess = await webcontainer.spawn('npm', ['run', 'build']);
|
| 325 |
+
|
| 326 |
+
let output = '';
|
| 327 |
+
buildProcess.output.pipeTo(
|
| 328 |
+
new WritableStream({
|
| 329 |
+
write(data) {
|
| 330 |
+
output += data;
|
| 331 |
+
},
|
| 332 |
+
}),
|
| 333 |
+
);
|
| 334 |
+
|
| 335 |
+
const exitCode = await buildProcess.exit;
|
| 336 |
+
|
| 337 |
+
if (exitCode !== 0) {
|
| 338 |
+
throw new ActionCommandError('Build Failed', output || 'No Output Available');
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
// Get the build output directory path
|
| 342 |
+
const buildDir = path.join(webcontainer.workdir, 'dist');
|
| 343 |
+
|
| 344 |
+
return {
|
| 345 |
+
path: buildDir,
|
| 346 |
+
exitCode,
|
| 347 |
+
output,
|
| 348 |
+
};
|
| 349 |
+
}
|
| 350 |
}
|
app/lib/stores/netlify.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { atom } from 'nanostores';
|
| 2 |
+
import type { NetlifyConnection } from '~/types/netlify';
|
| 3 |
+
import { logStore } from './logs';
|
| 4 |
+
import { toast } from 'react-toastify';
|
| 5 |
+
|
| 6 |
+
// Initialize with stored connection or defaults
|
| 7 |
+
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
|
| 8 |
+
const initialConnection: NetlifyConnection = storedConnection
|
| 9 |
+
? JSON.parse(storedConnection)
|
| 10 |
+
: {
|
| 11 |
+
user: null,
|
| 12 |
+
token: '',
|
| 13 |
+
stats: undefined,
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export const netlifyConnection = atom<NetlifyConnection>(initialConnection);
|
| 17 |
+
export const isConnecting = atom<boolean>(false);
|
| 18 |
+
export const isFetchingStats = atom<boolean>(false);
|
| 19 |
+
|
| 20 |
+
export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) => {
|
| 21 |
+
const currentState = netlifyConnection.get();
|
| 22 |
+
const newState = { ...currentState, ...updates };
|
| 23 |
+
netlifyConnection.set(newState);
|
| 24 |
+
|
| 25 |
+
// Persist to localStorage
|
| 26 |
+
if (typeof window !== 'undefined') {
|
| 27 |
+
localStorage.setItem('netlify_connection', JSON.stringify(newState));
|
| 28 |
+
}
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
export async function fetchNetlifyStats(token: string) {
|
| 32 |
+
try {
|
| 33 |
+
isFetchingStats.set(true);
|
| 34 |
+
|
| 35 |
+
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
| 36 |
+
headers: {
|
| 37 |
+
Authorization: `Bearer ${token}`,
|
| 38 |
+
'Content-Type': 'application/json',
|
| 39 |
+
},
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
if (!sitesResponse.ok) {
|
| 43 |
+
throw new Error(`Failed to fetch sites: ${sitesResponse.status}`);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const sites = (await sitesResponse.json()) as any;
|
| 47 |
+
|
| 48 |
+
const currentState = netlifyConnection.get();
|
| 49 |
+
updateNetlifyConnection({
|
| 50 |
+
...currentState,
|
| 51 |
+
stats: {
|
| 52 |
+
sites,
|
| 53 |
+
totalSites: sites.length,
|
| 54 |
+
},
|
| 55 |
+
});
|
| 56 |
+
} catch (error) {
|
| 57 |
+
console.error('Netlify API Error:', error);
|
| 58 |
+
logStore.logError('Failed to fetch Netlify stats', { error });
|
| 59 |
+
toast.error('Failed to fetch Netlify statistics');
|
| 60 |
+
} finally {
|
| 61 |
+
isFetchingStats.set(false);
|
| 62 |
+
}
|
| 63 |
+
}
|
app/lib/stores/streaming.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { atom } from 'nanostores';
|
| 2 |
+
|
| 3 |
+
export const streamingState = atom<boolean>(false);
|
app/routes/api.deploy.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
|
| 2 |
+
import crypto from 'crypto';
|
| 3 |
+
import type { NetlifySiteInfo } from '~/types/netlify';
|
| 4 |
+
|
| 5 |
+
interface DeployRequestBody {
|
| 6 |
+
siteId?: string;
|
| 7 |
+
files: Record<string, string>;
|
| 8 |
+
chatId: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export async function action({ request }: ActionFunctionArgs) {
|
| 12 |
+
try {
|
| 13 |
+
const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
|
| 14 |
+
|
| 15 |
+
if (!token) {
|
| 16 |
+
return json({ error: 'Not connected to Netlify' }, { status: 401 });
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
let targetSiteId = siteId;
|
| 20 |
+
let siteInfo: NetlifySiteInfo | undefined;
|
| 21 |
+
|
| 22 |
+
// If no siteId provided, create a new site
|
| 23 |
+
if (!targetSiteId) {
|
| 24 |
+
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
|
| 25 |
+
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
| 26 |
+
method: 'POST',
|
| 27 |
+
headers: {
|
| 28 |
+
Authorization: `Bearer ${token}`,
|
| 29 |
+
'Content-Type': 'application/json',
|
| 30 |
+
},
|
| 31 |
+
body: JSON.stringify({
|
| 32 |
+
name: siteName,
|
| 33 |
+
custom_domain: null,
|
| 34 |
+
}),
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
if (!createSiteResponse.ok) {
|
| 38 |
+
return json({ error: 'Failed to create site' }, { status: 400 });
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const newSite = (await createSiteResponse.json()) as any;
|
| 42 |
+
targetSiteId = newSite.id;
|
| 43 |
+
siteInfo = {
|
| 44 |
+
id: newSite.id,
|
| 45 |
+
name: newSite.name,
|
| 46 |
+
url: newSite.url,
|
| 47 |
+
chatId,
|
| 48 |
+
};
|
| 49 |
+
} else {
|
| 50 |
+
// Get existing site info
|
| 51 |
+
if (targetSiteId) {
|
| 52 |
+
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, {
|
| 53 |
+
headers: {
|
| 54 |
+
Authorization: `Bearer ${token}`,
|
| 55 |
+
},
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
if (siteResponse.ok) {
|
| 59 |
+
const existingSite = (await siteResponse.json()) as any;
|
| 60 |
+
siteInfo = {
|
| 61 |
+
id: existingSite.id,
|
| 62 |
+
name: existingSite.name,
|
| 63 |
+
url: existingSite.url,
|
| 64 |
+
chatId,
|
| 65 |
+
};
|
| 66 |
+
} else {
|
| 67 |
+
targetSiteId = undefined;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// If no siteId provided or site doesn't exist, create a new site
|
| 72 |
+
if (!targetSiteId) {
|
| 73 |
+
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
|
| 74 |
+
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
| 75 |
+
method: 'POST',
|
| 76 |
+
headers: {
|
| 77 |
+
Authorization: `Bearer ${token}`,
|
| 78 |
+
'Content-Type': 'application/json',
|
| 79 |
+
},
|
| 80 |
+
body: JSON.stringify({
|
| 81 |
+
name: siteName,
|
| 82 |
+
custom_domain: null,
|
| 83 |
+
}),
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
if (!createSiteResponse.ok) {
|
| 87 |
+
return json({ error: 'Failed to create site' }, { status: 400 });
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const newSite = (await createSiteResponse.json()) as any;
|
| 91 |
+
targetSiteId = newSite.id;
|
| 92 |
+
siteInfo = {
|
| 93 |
+
id: newSite.id,
|
| 94 |
+
name: newSite.name,
|
| 95 |
+
url: newSite.url,
|
| 96 |
+
chatId,
|
| 97 |
+
};
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// Create file digests
|
| 102 |
+
const fileDigests: Record<string, string> = {};
|
| 103 |
+
|
| 104 |
+
for (const [filePath, content] of Object.entries(files)) {
|
| 105 |
+
// Ensure file path starts with a forward slash
|
| 106 |
+
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
| 107 |
+
const hash = crypto.createHash('sha1').update(content).digest('hex');
|
| 108 |
+
fileDigests[normalizedPath] = hash;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Create a new deploy with digests
|
| 112 |
+
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, {
|
| 113 |
+
method: 'POST',
|
| 114 |
+
headers: {
|
| 115 |
+
Authorization: `Bearer ${token}`,
|
| 116 |
+
'Content-Type': 'application/json',
|
| 117 |
+
},
|
| 118 |
+
body: JSON.stringify({
|
| 119 |
+
files: fileDigests,
|
| 120 |
+
async: true,
|
| 121 |
+
skip_processing: false,
|
| 122 |
+
draft: false, // Change this to false for production deployments
|
| 123 |
+
function_schedules: [],
|
| 124 |
+
required: Object.keys(fileDigests), // Add this line
|
| 125 |
+
framework: null,
|
| 126 |
+
}),
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
if (!deployResponse.ok) {
|
| 130 |
+
return json({ error: 'Failed to create deployment' }, { status: 400 });
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
const deploy = (await deployResponse.json()) as any;
|
| 134 |
+
let retryCount = 0;
|
| 135 |
+
const maxRetries = 60;
|
| 136 |
+
|
| 137 |
+
// Poll until deploy is ready for file uploads
|
| 138 |
+
while (retryCount < maxRetries) {
|
| 139 |
+
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
|
| 140 |
+
headers: {
|
| 141 |
+
Authorization: `Bearer ${token}`,
|
| 142 |
+
},
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
const status = (await statusResponse.json()) as any;
|
| 146 |
+
|
| 147 |
+
if (status.state === 'prepared' || status.state === 'uploaded') {
|
| 148 |
+
// Upload all files regardless of required array
|
| 149 |
+
for (const [filePath, content] of Object.entries(files)) {
|
| 150 |
+
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
| 151 |
+
|
| 152 |
+
let uploadSuccess = false;
|
| 153 |
+
let uploadRetries = 0;
|
| 154 |
+
|
| 155 |
+
while (!uploadSuccess && uploadRetries < 3) {
|
| 156 |
+
try {
|
| 157 |
+
const uploadResponse = await fetch(
|
| 158 |
+
`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`,
|
| 159 |
+
{
|
| 160 |
+
method: 'PUT',
|
| 161 |
+
headers: {
|
| 162 |
+
Authorization: `Bearer ${token}`,
|
| 163 |
+
'Content-Type': 'application/octet-stream',
|
| 164 |
+
},
|
| 165 |
+
body: content,
|
| 166 |
+
},
|
| 167 |
+
);
|
| 168 |
+
|
| 169 |
+
uploadSuccess = uploadResponse.ok;
|
| 170 |
+
|
| 171 |
+
if (!uploadSuccess) {
|
| 172 |
+
console.error('Upload failed:', await uploadResponse.text());
|
| 173 |
+
uploadRetries++;
|
| 174 |
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
| 175 |
+
}
|
| 176 |
+
} catch (error) {
|
| 177 |
+
console.error('Upload error:', error);
|
| 178 |
+
uploadRetries++;
|
| 179 |
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
if (!uploadSuccess) {
|
| 184 |
+
return json({ error: `Failed to upload file ${filePath}` }, { status: 500 });
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
if (status.state === 'ready') {
|
| 190 |
+
// Only return after files are uploaded
|
| 191 |
+
if (Object.keys(files).length === 0 || status.summary?.status === 'ready') {
|
| 192 |
+
return json({
|
| 193 |
+
success: true,
|
| 194 |
+
deploy: {
|
| 195 |
+
id: status.id,
|
| 196 |
+
state: status.state,
|
| 197 |
+
url: status.ssl_url || status.url,
|
| 198 |
+
},
|
| 199 |
+
site: siteInfo,
|
| 200 |
+
});
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
if (status.state === 'error') {
|
| 205 |
+
return json({ error: status.error_message || 'Deploy preparation failed' }, { status: 500 });
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
retryCount++;
|
| 209 |
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
if (retryCount >= maxRetries) {
|
| 213 |
+
return json({ error: 'Deploy preparation timed out' }, { status: 500 });
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// Make sure we're returning the deploy ID and site info
|
| 217 |
+
return json({
|
| 218 |
+
success: true,
|
| 219 |
+
deploy: {
|
| 220 |
+
id: deploy.id,
|
| 221 |
+
state: deploy.state,
|
| 222 |
+
},
|
| 223 |
+
site: siteInfo,
|
| 224 |
+
});
|
| 225 |
+
} catch (error) {
|
| 226 |
+
console.error('Deploy error:', error);
|
| 227 |
+
return json({ error: 'Deployment failed' }, { status: 500 });
|
| 228 |
+
}
|
| 229 |
+
}
|
app/types/actions.ts
CHANGED
|
@@ -17,7 +17,11 @@ export interface StartAction extends BaseAction {
|
|
| 17 |
type: 'start';
|
| 18 |
}
|
| 19 |
|
| 20 |
-
export
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
export type BoltActionData = BoltAction | BaseAction;
|
| 23 |
|
|
|
|
| 17 |
type: 'start';
|
| 18 |
}
|
| 19 |
|
| 20 |
+
export interface BuildAction extends BaseAction {
|
| 21 |
+
type: 'build';
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export type BoltAction = FileAction | ShellAction | StartAction | BuildAction;
|
| 25 |
|
| 26 |
export type BoltActionData = BoltAction | BaseAction;
|
| 27 |
|
app/types/netlify.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface NetlifySite {
|
| 2 |
+
id: string;
|
| 3 |
+
name: string;
|
| 4 |
+
url: string;
|
| 5 |
+
admin_url: string;
|
| 6 |
+
build_settings: {
|
| 7 |
+
provider: string;
|
| 8 |
+
repo_url: string;
|
| 9 |
+
cmd: string;
|
| 10 |
+
};
|
| 11 |
+
published_deploy: {
|
| 12 |
+
published_at: string;
|
| 13 |
+
deploy_time: number;
|
| 14 |
+
};
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface NetlifyUser {
|
| 18 |
+
id: string;
|
| 19 |
+
slug: string;
|
| 20 |
+
email: string;
|
| 21 |
+
full_name: string;
|
| 22 |
+
avatar_url: string;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export interface NetlifyStats {
|
| 26 |
+
sites: NetlifySite[];
|
| 27 |
+
totalSites: number;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export interface NetlifyConnection {
|
| 31 |
+
user: NetlifyUser | null;
|
| 32 |
+
token: string;
|
| 33 |
+
stats?: NetlifyStats;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export interface NetlifySiteInfo {
|
| 37 |
+
id: string;
|
| 38 |
+
name: string;
|
| 39 |
+
url: string;
|
| 40 |
+
chatId: string;
|
| 41 |
+
}
|
uno.config.ts
CHANGED
|
@@ -98,9 +98,7 @@ const COLOR_PRIMITIVES = {
|
|
| 98 |
};
|
| 99 |
|
| 100 |
export default defineConfig({
|
| 101 |
-
safelist: [
|
| 102 |
-
...Object.keys(customIconCollection[collectionName] || {}).map(x => `i-bolt:${x}`)
|
| 103 |
-
],
|
| 104 |
shortcuts: {
|
| 105 |
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
| 106 |
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|
|
|
|
| 98 |
};
|
| 99 |
|
| 100 |
export default defineConfig({
|
| 101 |
+
safelist: [...Object.keys(customIconCollection[collectionName] || {}).map((x) => `i-bolt:${x}`)],
|
|
|
|
|
|
|
| 102 |
shortcuts: {
|
| 103 |
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
| 104 |
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|