pauloj commited on
Commit
a8d8b7b
·
unverified ·
2 Parent(s): 1098188 5d9bb00

Merge branch 'main' into diff-view-v2

Browse files
.github/workflows/docker.yaml CHANGED
@@ -1,14 +1,11 @@
1
- ---
2
  name: Docker Publish
3
 
4
  on:
5
- workflow_dispatch:
6
  push:
7
  branches:
8
  - main
9
- tags:
10
- - v*
11
- - '*'
12
 
13
  permissions:
14
  packages: write
@@ -16,66 +13,49 @@ permissions:
16
 
17
  env:
18
  REGISTRY: ghcr.io
19
- DOCKER_IMAGE: ghcr.io/${{ github.repository }}
20
- BUILD_TARGET: bolt-ai-production # bolt-ai-development
21
 
22
  jobs:
23
  docker-build-publish:
24
  runs-on: ubuntu-latest
25
  steps:
26
- - name: Checkout
27
  uses: actions/checkout@v4
28
 
29
- - id: string
30
- uses: ASzc/change-string-case-action@v6
31
- with:
32
- string: ${{ env.DOCKER_IMAGE }}
33
-
34
- - name: Docker meta
35
- id: meta
36
- uses: crazy-max/ghaction-docker-meta@v5
37
- with:
38
- images: ${{ steps.string.outputs.lowercase }}
39
- flavor: |
40
- latest=true
41
- prefix=
42
- suffix=
43
- tags: |
44
- type=semver,pattern={{version}}
45
- type=pep440,pattern={{version}}
46
- type=ref,event=tag
47
- type=raw,value={{sha}}
48
-
49
- - name: Set up QEMU
50
- uses: docker/setup-qemu-action@v3
51
-
52
  - name: Set up Docker Buildx
53
  uses: docker/setup-buildx-action@v3
54
 
55
- - name: Login to Container Registry
56
  uses: docker/login-action@v3
57
  with:
58
  registry: ${{ env.REGISTRY }}
59
- username: ${{ github.actor }} # ${{ secrets.DOCKER_USERNAME }}
60
- password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }}
 
 
 
 
 
 
61
 
62
- - name: Build and push
 
63
  uses: docker/build-push-action@v6
64
  with:
65
  context: .
66
- file: ./Dockerfile
67
- target: ${{ env.BUILD_TARGET }}
68
- platforms: linux/amd64,linux/arm64
69
  push: true
70
- tags: ${{ steps.meta.outputs.tags }}
 
 
71
  labels: ${{ steps.meta.outputs.labels }}
72
- cache-from: type=registry,ref=${{ steps.string.outputs.lowercase }}:latest
73
- cache-to: type=inline
74
 
75
- - name: Check manifest
76
- run: |
77
- docker buildx imagetools inspect ${{ steps.string.outputs.lowercase }}:${{ steps.meta.outputs.version }}
78
-
79
- - name: Dump context
80
- if: always()
81
- uses: crazy-max/ghaction-dump-context@v2
 
 
 
 
 
1
  name: Docker Publish
2
 
3
  on:
 
4
  push:
5
  branches:
6
  - main
7
+ - stable
8
+ workflow_dispatch:
 
9
 
10
  permissions:
11
  packages: write
 
13
 
14
  env:
15
  REGISTRY: ghcr.io
16
+ IMAGE_NAME: ${{ github.repository }}
 
17
 
18
  jobs:
19
  docker-build-publish:
20
  runs-on: ubuntu-latest
21
  steps:
22
+ - name: Checkout code
23
  uses: actions/checkout@v4
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  - name: Set up Docker Buildx
26
  uses: docker/setup-buildx-action@v3
27
 
28
+ - name: Log in to GitHub Container Registry
29
  uses: docker/login-action@v3
30
  with:
31
  registry: ${{ env.REGISTRY }}
32
+ username: ${{ github.actor }}
33
+ password: ${{ secrets.GITHUB_TOKEN }}
34
+
35
+ - name: Extract metadata for Docker image
36
+ id: meta
37
+ uses: docker/metadata-action@v4
38
+ with:
39
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
40
 
41
+ - name: Build and push Docker image for main
42
+ if: github.ref == 'refs/heads/main'
43
  uses: docker/build-push-action@v6
44
  with:
45
  context: .
 
 
 
46
  push: true
47
+ tags: |
48
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
49
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
50
  labels: ${{ steps.meta.outputs.labels }}
 
 
51
 
52
+ - name: Build and push Docker image for stable
53
+ if: github.ref == 'refs/heads/stable'
54
+ uses: docker/build-push-action@v6
55
+ with:
56
+ context: .
57
+ push: true
58
+ tags: |
59
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable
60
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
61
+ labels: ${{ steps.meta.outputs.labels }}
Dockerfile CHANGED
@@ -6,9 +6,10 @@ WORKDIR /app
6
  # Install dependencies (this step is cached as long as the dependencies don't change)
7
  COPY package.json pnpm-lock.yaml ./
8
 
9
- RUN npm install -g corepack@latest
10
 
11
- RUN corepack enable pnpm && pnpm install
 
12
 
13
  # Copy the rest of your app's source code
14
  COPY . .
 
6
  # Install dependencies (this step is cached as long as the dependencies don't change)
7
  COPY package.json pnpm-lock.yaml ./
8
 
9
+ #RUN npm install -g corepack@latest
10
 
11
+ #RUN corepack enable pnpm && pnpm install
12
+ RUN npm install -g pnpm && pnpm install
13
 
14
  # Copy the rest of your app's source code
15
  COPY . .
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 { toast } from 'react-toastify';
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
- {/* GitHub Connection */}
260
- <motion.div
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
@@ -46,6 +46,7 @@ interface BaseChatProps {
46
  showChat?: boolean;
47
  chatStarted?: boolean;
48
  isStreaming?: boolean;
 
49
  messages?: Message[];
50
  description?: string;
51
  enhancingPrompt?: boolean;
@@ -81,6 +82,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
81
  showChat = true,
82
  chatStarted = false,
83
  isStreaming = false,
 
84
  model,
85
  setModel,
86
  provider,
@@ -129,6 +131,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
129
  console.log(transcript);
130
  }, [transcript]);
131
 
 
 
 
 
132
  useEffect(() => {
133
  if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
134
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
 
46
  showChat?: boolean;
47
  chatStarted?: boolean;
48
  isStreaming?: boolean;
49
+ onStreamingChange?: (streaming: boolean) => void;
50
  messages?: Message[];
51
  description?: string;
52
  enhancingPrompt?: boolean;
 
82
  showChat = true,
83
  chatStarted = false,
84
  isStreaming = false,
85
+ onStreamingChange,
86
  model,
87
  setModel,
88
  provider,
 
131
  console.log(transcript);
132
  }, [transcript]);
133
 
134
+ useEffect(() => {
135
+ onStreamingChange?.(isStreaming);
136
+ }, [isStreaming, onStreamingChange]);
137
+
138
  useEffect(() => {
139
  if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
140
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
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('flex items-center p-1.5', {
60
- 'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
61
- !active,
62
- 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
63
- 'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
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
 
@@ -299,6 +307,7 @@ export class ActionRunner {
299
  logger.error('Failed to write file\n\n', error);
300
  }
301
  }
 
302
  #updateAction(id: string, newState: ActionStateUpdate) {
303
  const actions = this.actions.get();
304
 
@@ -331,4 +340,39 @@ export class ActionRunner {
331
  #getHistoryPath(filePath: string) {
332
  return nodePath.join('.history', filePath);
333
  }
334
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
 
307
  logger.error('Failed to write file\n\n', error);
308
  }
309
  }
310
+
311
  #updateAction(id: string, newState: ActionStateUpdate) {
312
  const actions = this.actions.get();
313
 
 
340
  #getHistoryPath(filePath: string) {
341
  return nodePath.join('.history', filePath);
342
  }
343
+
344
+ async #runBuildAction(action: ActionState) {
345
+ if (action.type !== 'build') {
346
+ unreachable('Expected build action');
347
+ }
348
+
349
+ const webcontainer = await this.#webcontainer;
350
+
351
+ // Create a new terminal specifically for the build
352
+ const buildProcess = await webcontainer.spawn('npm', ['run', 'build']);
353
+
354
+ let output = '';
355
+ buildProcess.output.pipeTo(
356
+ new WritableStream({
357
+ write(data) {
358
+ output += data;
359
+ },
360
+ }),
361
+ );
362
+
363
+ const exitCode = await buildProcess.exit;
364
+
365
+ if (exitCode !== 0) {
366
+ throw new ActionCommandError('Build Failed', output || 'No Output Available');
367
+ }
368
+
369
+ // Get the build output directory path
370
+ const buildDir = nodePath.join(webcontainer.workdir, 'dist');
371
+
372
+ return {
373
+ path: buildDir,
374
+ exitCode,
375
+ output,
376
+ };
377
+ }
378
+ }
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
@@ -19,7 +19,11 @@ export interface StartAction extends BaseAction {
19
  type: 'start';
20
  }
21
 
22
- export type BoltAction = FileAction | ShellAction | StartAction;
 
 
 
 
23
 
24
  export type BoltActionData = BoltAction | BaseAction;
25
 
 
19
  type: 'start';
20
  }
21
 
22
+ export interface BuildAction extends BaseAction {
23
+ type: 'build';
24
+ }
25
+
26
+ export type BoltAction = FileAction | ShellAction | StartAction | BuildAction;
27
 
28
  export type BoltActionData = BoltAction | BaseAction;
29
 
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',