@prmichaelsen/acp-visualizer 0.10.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ import { Route as SearchRouteImport } from './routes/search'
14
14
  import { Route as ReportsRouteImport } from './routes/reports'
15
15
  import { Route as PatternsRouteImport } from './routes/patterns'
16
16
  import { Route as MilestonesRouteImport } from './routes/milestones'
17
+ import { Route as GithubRouteImport } from './routes/github'
17
18
  import { Route as DesignsRouteImport } from './routes/designs'
18
19
  import { Route as ActivityRouteImport } from './routes/activity'
19
20
  import { Route as IndexRouteImport } from './routes/index'
@@ -28,6 +29,7 @@ import { Route as PatternsSlugRouteImport } from './routes/patterns.$slug'
28
29
  import { Route as MilestonesMilestoneIdRouteImport } from './routes/milestones.$milestoneId'
29
30
  import { Route as DesignsSlugRouteImport } from './routes/designs.$slug'
30
31
  import { Route as ApiWatchRouteImport } from './routes/api/watch'
32
+ import { Route as AuthGithubCallbackRouteImport } from './routes/auth/github/callback'
31
33
 
32
34
  const TasksRoute = TasksRouteImport.update({
33
35
  id: '/tasks',
@@ -54,6 +56,11 @@ const MilestonesRoute = MilestonesRouteImport.update({
54
56
  path: '/milestones',
55
57
  getParentRoute: () => rootRouteImport,
56
58
  } as any)
59
+ const GithubRoute = GithubRouteImport.update({
60
+ id: '/github',
61
+ path: '/github',
62
+ getParentRoute: () => rootRouteImport,
63
+ } as any)
57
64
  const DesignsRoute = DesignsRouteImport.update({
58
65
  id: '/designs',
59
66
  path: '/designs',
@@ -124,11 +131,17 @@ const ApiWatchRoute = ApiWatchRouteImport.update({
124
131
  path: '/api/watch',
125
132
  getParentRoute: () => rootRouteImport,
126
133
  } as any)
134
+ const AuthGithubCallbackRoute = AuthGithubCallbackRouteImport.update({
135
+ id: '/auth/github/callback',
136
+ path: '/auth/github/callback',
137
+ getParentRoute: () => rootRouteImport,
138
+ } as any)
127
139
 
128
140
  export interface FileRoutesByFullPath {
129
141
  '/': typeof IndexRoute
130
142
  '/activity': typeof ActivityRoute
131
143
  '/designs': typeof DesignsRouteWithChildren
144
+ '/github': typeof GithubRoute
132
145
  '/milestones': typeof MilestonesRouteWithChildren
133
146
  '/patterns': typeof PatternsRouteWithChildren
134
147
  '/reports': typeof ReportsRouteWithChildren
@@ -145,10 +158,12 @@ export interface FileRoutesByFullPath {
145
158
  '/patterns/': typeof PatternsIndexRoute
146
159
  '/reports/': typeof ReportsIndexRoute
147
160
  '/tasks/': typeof TasksIndexRoute
161
+ '/auth/github/callback': typeof AuthGithubCallbackRoute
148
162
  }
149
163
  export interface FileRoutesByTo {
150
164
  '/': typeof IndexRoute
151
165
  '/activity': typeof ActivityRoute
166
+ '/github': typeof GithubRoute
152
167
  '/search': typeof SearchRoute
153
168
  '/api/watch': typeof ApiWatchRoute
154
169
  '/designs/$slug': typeof DesignsSlugRoute
@@ -161,12 +176,14 @@ export interface FileRoutesByTo {
161
176
  '/patterns': typeof PatternsIndexRoute
162
177
  '/reports': typeof ReportsIndexRoute
163
178
  '/tasks': typeof TasksIndexRoute
179
+ '/auth/github/callback': typeof AuthGithubCallbackRoute
164
180
  }
165
181
  export interface FileRoutesById {
166
182
  __root__: typeof rootRouteImport
167
183
  '/': typeof IndexRoute
168
184
  '/activity': typeof ActivityRoute
169
185
  '/designs': typeof DesignsRouteWithChildren
186
+ '/github': typeof GithubRoute
170
187
  '/milestones': typeof MilestonesRouteWithChildren
171
188
  '/patterns': typeof PatternsRouteWithChildren
172
189
  '/reports': typeof ReportsRouteWithChildren
@@ -183,6 +200,7 @@ export interface FileRoutesById {
183
200
  '/patterns/': typeof PatternsIndexRoute
184
201
  '/reports/': typeof ReportsIndexRoute
185
202
  '/tasks/': typeof TasksIndexRoute
203
+ '/auth/github/callback': typeof AuthGithubCallbackRoute
186
204
  }
187
205
  export interface FileRouteTypes {
188
206
  fileRoutesByFullPath: FileRoutesByFullPath
@@ -190,6 +208,7 @@ export interface FileRouteTypes {
190
208
  | '/'
191
209
  | '/activity'
192
210
  | '/designs'
211
+ | '/github'
193
212
  | '/milestones'
194
213
  | '/patterns'
195
214
  | '/reports'
@@ -206,10 +225,12 @@ export interface FileRouteTypes {
206
225
  | '/patterns/'
207
226
  | '/reports/'
208
227
  | '/tasks/'
228
+ | '/auth/github/callback'
209
229
  fileRoutesByTo: FileRoutesByTo
210
230
  to:
211
231
  | '/'
212
232
  | '/activity'
233
+ | '/github'
213
234
  | '/search'
214
235
  | '/api/watch'
215
236
  | '/designs/$slug'
@@ -222,11 +243,13 @@ export interface FileRouteTypes {
222
243
  | '/patterns'
223
244
  | '/reports'
224
245
  | '/tasks'
246
+ | '/auth/github/callback'
225
247
  id:
226
248
  | '__root__'
227
249
  | '/'
228
250
  | '/activity'
229
251
  | '/designs'
252
+ | '/github'
230
253
  | '/milestones'
231
254
  | '/patterns'
232
255
  | '/reports'
@@ -243,18 +266,21 @@ export interface FileRouteTypes {
243
266
  | '/patterns/'
244
267
  | '/reports/'
245
268
  | '/tasks/'
269
+ | '/auth/github/callback'
246
270
  fileRoutesById: FileRoutesById
247
271
  }
248
272
  export interface RootRouteChildren {
249
273
  IndexRoute: typeof IndexRoute
250
274
  ActivityRoute: typeof ActivityRoute
251
275
  DesignsRoute: typeof DesignsRouteWithChildren
276
+ GithubRoute: typeof GithubRoute
252
277
  MilestonesRoute: typeof MilestonesRouteWithChildren
253
278
  PatternsRoute: typeof PatternsRouteWithChildren
254
279
  ReportsRoute: typeof ReportsRouteWithChildren
255
280
  SearchRoute: typeof SearchRoute
256
281
  TasksRoute: typeof TasksRouteWithChildren
257
282
  ApiWatchRoute: typeof ApiWatchRoute
283
+ AuthGithubCallbackRoute: typeof AuthGithubCallbackRoute
258
284
  }
259
285
 
260
286
  declare module '@tanstack/react-router' {
@@ -294,6 +320,13 @@ declare module '@tanstack/react-router' {
294
320
  preLoaderRoute: typeof MilestonesRouteImport
295
321
  parentRoute: typeof rootRouteImport
296
322
  }
323
+ '/github': {
324
+ id: '/github'
325
+ path: '/github'
326
+ fullPath: '/github'
327
+ preLoaderRoute: typeof GithubRouteImport
328
+ parentRoute: typeof rootRouteImport
329
+ }
297
330
  '/designs': {
298
331
  id: '/designs'
299
332
  path: '/designs'
@@ -392,6 +425,13 @@ declare module '@tanstack/react-router' {
392
425
  preLoaderRoute: typeof ApiWatchRouteImport
393
426
  parentRoute: typeof rootRouteImport
394
427
  }
428
+ '/auth/github/callback': {
429
+ id: '/auth/github/callback'
430
+ path: '/auth/github/callback'
431
+ fullPath: '/auth/github/callback'
432
+ preLoaderRoute: typeof AuthGithubCallbackRouteImport
433
+ parentRoute: typeof rootRouteImport
434
+ }
395
435
  }
396
436
  }
397
437
 
@@ -465,12 +505,14 @@ const rootRouteChildren: RootRouteChildren = {
465
505
  IndexRoute: IndexRoute,
466
506
  ActivityRoute: ActivityRoute,
467
507
  DesignsRoute: DesignsRouteWithChildren,
508
+ GithubRoute: GithubRoute,
468
509
  MilestonesRoute: MilestonesRouteWithChildren,
469
510
  PatternsRoute: PatternsRouteWithChildren,
470
511
  ReportsRoute: ReportsRouteWithChildren,
471
512
  SearchRoute: SearchRoute,
472
513
  TasksRoute: TasksRouteWithChildren,
473
514
  ApiWatchRoute: ApiWatchRoute,
515
+ AuthGithubCallbackRoute: AuthGithubCallbackRoute,
474
516
  }
475
517
  export const routeTree = rootRouteImport
476
518
  ._addFileChildren(rootRouteChildren)
@@ -1,13 +1,14 @@
1
- import { HeadContent, Scripts, createRootRoute, Outlet, useRouter, useRouterState } from '@tanstack/react-router'
1
+ import { HeadContent, Scripts, createRootRoute, Outlet } from '@tanstack/react-router'
2
2
  import { useState, useCallback, useEffect } from 'react'
3
3
  import { Menu, X } from 'lucide-react'
4
4
  import { useAutoRefresh } from '../lib/useAutoRefresh'
5
5
  import { Sidebar } from '../components/Sidebar'
6
6
  import { Header } from '../components/Header'
7
7
  import { SidePanel } from '../components/SidePanel'
8
- import { getProgressData } from '../services/progress-database.service'
8
+ import { getProgressData, type ProgressResult } from '../services/progress-database.service'
9
9
  import { listProjects, getProjectProgressPath } from '../services/projects.service'
10
- import { fetchGitHubProgress } from '../services/github.service'
10
+ import { fetchGitHubProgress, type GitHubResult } from '../services/github.service'
11
+ import { getStoredToken } from '../lib/github-auth'
11
12
  import type { ProgressData } from '../lib/types'
12
13
  import type { AcpProject } from '../services/projects.service'
13
14
  import { ProgressProvider } from '../contexts/ProgressContext'
@@ -28,7 +29,7 @@ export const Route = createRootRoute({
28
29
  if (!import.meta.env.VITE_HOSTED) {
29
30
  try {
30
31
  const [result, projectList] = await Promise.all([
31
- getProgressData({ data: {} }),
32
+ getProgressData({ data: {} }) as Promise<ProgressResult>,
32
33
  listProjects(),
33
34
  ])
34
35
  if (result.ok) {
@@ -111,6 +112,7 @@ function RootLayout() {
111
112
  )
112
113
  const [initialLoadDone, setInitialLoadDone] = useState(false)
113
114
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
115
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
114
116
 
115
117
  // On mount, check for ?repo= param and auto-load
116
118
  useEffect(() => {
@@ -120,7 +122,8 @@ function RootLayout() {
120
122
  const repoParam = getRepoFromUrl()
121
123
  // Repo param takes precedence over default local data
122
124
  if (repoParam) {
123
- fetchGitHubProgress({ data: repoParam }).then((result) => {
125
+ const token = getStoredToken()
126
+ void (fetchGitHubProgress({ data: { ...repoParam, token: token || undefined } }) as Promise<GitHubResult>).then((result) => {
124
127
  if (result.ok) {
125
128
  setProgressData(result.data)
126
129
  setCurrentProject(`${repoParam.owner}/${repoParam.repo}`)
@@ -130,7 +133,8 @@ function RootLayout() {
130
133
  }, [initialLoadDone])
131
134
 
132
135
  const handleGitHubLoad = useCallback(async (owner: string, repo: string) => {
133
- const result = await fetchGitHubProgress({ data: { owner, repo } })
136
+ const token = getStoredToken()
137
+ const result = await (fetchGitHubProgress({ data: { owner, repo, token: token || undefined } }) as Promise<GitHubResult>)
134
138
  if (result.ok) {
135
139
  setProgressData(result.data)
136
140
  setCurrentProject(`${owner}/${repo}`)
@@ -144,7 +148,7 @@ function RootLayout() {
144
148
  try {
145
149
  const path = await getProjectProgressPath({ data: { projectId } })
146
150
  if (path) {
147
- const result = await getProgressData({ data: { path } })
151
+ const result = await (getProgressData({ data: { path } }) as Promise<ProgressResult>)
148
152
  if (result.ok) {
149
153
  setProgressData(result.data)
150
154
  setCurrentProject(projectId)
@@ -193,6 +197,8 @@ function RootLayout() {
193
197
  onProjectSelect={handleProjectSwitch}
194
198
  onGitHubLoad={handleGitHubLoad}
195
199
  onClose={() => setMobileMenuOpen(false)}
200
+ isCollapsed={sidebarCollapsed}
201
+ onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
196
202
  />
197
203
  </div>
198
204
 
@@ -0,0 +1,86 @@
1
+ import { createFileRoute, useNavigate } from '@tanstack/react-router'
2
+ import { useEffect, useState } from 'react'
3
+ import { exchangeOAuthCode, fetchGitHubUser } from '../../../services/github-oauth.service'
4
+ import { setStoredToken, setStoredUser, validateOAuthState } from '../../../lib/github-auth'
5
+
6
+ export const Route = createFileRoute('/auth/github/callback')({
7
+ component: GitHubCallback,
8
+ })
9
+
10
+ function GitHubCallback() {
11
+ const navigate = useNavigate()
12
+ const [error, setError] = useState<string | null>(null)
13
+
14
+ useEffect(() => {
15
+ const params = new URLSearchParams(window.location.search)
16
+ const code = params.get('code')
17
+ const state = params.get('state')
18
+ const errorParam = params.get('error')
19
+
20
+ if (errorParam) {
21
+ setError(`GitHub OAuth error: ${errorParam}`)
22
+ return
23
+ }
24
+
25
+ if (!code || !state) {
26
+ setError('Missing OAuth parameters')
27
+ return
28
+ }
29
+
30
+ if (!validateOAuthState(state)) {
31
+ setError('Invalid OAuth state - possible CSRF attack')
32
+ return
33
+ }
34
+
35
+ exchangeOAuthCode({ data: { code } })
36
+ .then(async (result) => {
37
+ if (!result.ok) {
38
+ setError(result.error)
39
+ return
40
+ }
41
+
42
+ // Store token
43
+ setStoredToken(result.token)
44
+
45
+ // Fetch user info
46
+ const userResult = await fetchGitHubUser({ data: { token: result.token } })
47
+ if (userResult.ok) {
48
+ setStoredUser(userResult.user)
49
+ }
50
+
51
+ // Redirect back to home
52
+ navigate({ to: '/' })
53
+ })
54
+ .catch((err) => {
55
+ setError(err instanceof Error ? err.message : 'Unknown error')
56
+ })
57
+ }, [navigate])
58
+
59
+ if (error) {
60
+ return (
61
+ <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
62
+ <div className="text-center max-w-md p-6">
63
+ <h2 className="text-xl font-semibold text-red-600 dark:text-red-400 mb-2">
64
+ Authentication Failed
65
+ </h2>
66
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{error}</p>
67
+ <button
68
+ onClick={() => navigate({ to: '/' })}
69
+ className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
70
+ >
71
+ Return Home
72
+ </button>
73
+ </div>
74
+ </div>
75
+ )
76
+ }
77
+
78
+ return (
79
+ <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
80
+ <div className="text-center">
81
+ <div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
82
+ <p className="text-sm text-gray-600 dark:text-gray-400">Connecting to GitHub...</p>
83
+ </div>
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,213 @@
1
+ import { createFileRoute, useNavigate } from '@tanstack/react-router'
2
+ import { useState, useEffect } from 'react'
3
+ import { Github, Lock, Star, GitFork, Loader2, LogIn } from 'lucide-react'
4
+ import { getStoredToken, getStoredUser } from '../lib/github-auth'
5
+ import { searchGitHubRepos } from '../services/github-oauth.service'
6
+
7
+ export const Route = createFileRoute('/github')({
8
+ component: GitHubRepoList,
9
+ })
10
+
11
+ interface Repo {
12
+ full_name: string
13
+ description: string | null
14
+ private: boolean
15
+ stargazers_count?: number
16
+ forks_count?: number
17
+ language?: string | null
18
+ updated_at?: string
19
+ }
20
+
21
+ function GitHubRepoList() {
22
+ const navigate = useNavigate()
23
+ const [repos, setRepos] = useState<Repo[]>([])
24
+ const [filteredRepos, setFilteredRepos] = useState<Repo[]>([])
25
+ const [searchQuery, setSearchQuery] = useState('')
26
+ const [loading, setLoading] = useState(true)
27
+ const [error, setError] = useState<string | null>(null)
28
+
29
+ const token = getStoredToken()
30
+ const user = getStoredUser()
31
+
32
+ useEffect(() => {
33
+ if (!token) {
34
+ setLoading(false)
35
+ return
36
+ }
37
+
38
+ searchGitHubRepos({ data: { token, query: '' } })
39
+ .then((result) => {
40
+ if (result.ok) {
41
+ setRepos(result.repos as Repo[])
42
+ setFilteredRepos(result.repos as Repo[])
43
+ } else {
44
+ setError(result.error)
45
+ }
46
+ })
47
+ .catch((err) => {
48
+ setError(err instanceof Error ? err.message : 'Failed to load repos')
49
+ })
50
+ .finally(() => {
51
+ setLoading(false)
52
+ })
53
+ }, [token])
54
+
55
+ useEffect(() => {
56
+ if (!searchQuery.trim()) {
57
+ setFilteredRepos(repos)
58
+ return
59
+ }
60
+
61
+ const query = searchQuery.toLowerCase()
62
+ const filtered = repos.filter((repo) =>
63
+ repo.full_name.toLowerCase().includes(query) ||
64
+ (repo.description && repo.description.toLowerCase().includes(query))
65
+ )
66
+ setFilteredRepos(filtered)
67
+ }, [searchQuery, repos])
68
+
69
+ const handleRepoClick = async (repo: Repo) => {
70
+ const [owner, repoName] = repo.full_name.split('/')
71
+ window.location.href = `/?repo=${owner}/${repoName}`
72
+ }
73
+
74
+ if (!token) {
75
+ return (
76
+ <div className="flex items-center justify-center h-full">
77
+ <div className="text-center max-w-md p-6">
78
+ <Github className="w-16 h-16 mx-auto mb-4 text-gray-400" />
79
+ <h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-2">
80
+ Sign in Required
81
+ </h2>
82
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
83
+ Sign in with GitHub to view and load your repositories.
84
+ </p>
85
+ <button
86
+ onClick={() => navigate({ to: '/' })}
87
+ className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
88
+ >
89
+ <LogIn className="w-4 h-4" />
90
+ Go to Home
91
+ </button>
92
+ </div>
93
+ </div>
94
+ )
95
+ }
96
+
97
+ if (loading) {
98
+ return (
99
+ <div className="flex items-center justify-center h-full">
100
+ <div className="text-center">
101
+ <Loader2 className="w-12 h-12 animate-spin mx-auto mb-4 text-blue-600" />
102
+ <p className="text-sm text-gray-600 dark:text-gray-400">Loading repositories...</p>
103
+ </div>
104
+ </div>
105
+ )
106
+ }
107
+
108
+ if (error) {
109
+ return (
110
+ <div className="flex items-center justify-center h-full">
111
+ <div className="text-center max-w-md p-6">
112
+ <h2 className="text-xl font-semibold text-red-600 dark:text-red-400 mb-2">
113
+ Error Loading Repositories
114
+ </h2>
115
+ <p className="text-sm text-gray-600 dark:text-gray-400">{error}</p>
116
+ </div>
117
+ </div>
118
+ )
119
+ }
120
+
121
+ return (
122
+ <div className="h-full flex flex-col">
123
+ {/* Header */}
124
+ <div className="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 p-4 lg:p-6">
125
+ <div className="max-w-5xl mx-auto">
126
+ <div className="flex items-center gap-3 mb-4">
127
+ <Github className="w-6 h-6 text-gray-700 dark:text-gray-300" />
128
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
129
+ GitHub Repositories
130
+ </h1>
131
+ </div>
132
+ {user && (
133
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
134
+ Showing repositories for <span className="font-medium text-gray-800 dark:text-gray-200">@{user.login}</span>
135
+ </p>
136
+ )}
137
+ {/* Search */}
138
+ <input
139
+ type="text"
140
+ value={searchQuery}
141
+ onChange={(e) => setSearchQuery(e.target.value)}
142
+ placeholder="Search repositories..."
143
+ className="w-full px-4 py-2 text-base bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-800 rounded-md text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
144
+ />
145
+ </div>
146
+ </div>
147
+
148
+ {/* Repo List */}
149
+ <div className="flex-1 overflow-auto">
150
+ <div className="max-w-5xl mx-auto p-4 lg:p-6">
151
+ {filteredRepos.length === 0 ? (
152
+ <div className="text-center py-12">
153
+ <p className="text-gray-600 dark:text-gray-400">
154
+ {searchQuery ? 'No repositories match your search.' : 'No repositories found.'}
155
+ </p>
156
+ </div>
157
+ ) : (
158
+ <div className="space-y-3">
159
+ {filteredRepos.map((repo) => (
160
+ <button
161
+ key={repo.full_name}
162
+ onClick={() => handleRepoClick(repo)}
163
+ className="w-full text-left p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg hover:border-gray-300 dark:hover:border-gray-700 hover:shadow-md transition-all"
164
+ >
165
+ <div className="flex items-start justify-between gap-4">
166
+ <div className="flex-1 min-w-0">
167
+ <div className="flex items-center gap-2 mb-2">
168
+ <h3 className="text-base font-semibold text-blue-600 dark:text-blue-400 truncate">
169
+ {repo.full_name}
170
+ </h3>
171
+ {repo.private && (
172
+ <span className="flex items-center gap-1 px-2 py-0.5 text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-500 rounded-full shrink-0">
173
+ <Lock className="w-3 h-3" />
174
+ Private
175
+ </span>
176
+ )}
177
+ </div>
178
+ {repo.description && (
179
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-2 line-clamp-2">
180
+ {repo.description}
181
+ </p>
182
+ )}
183
+ <div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-500">
184
+ {repo.language && (
185
+ <span className="flex items-center gap-1">
186
+ <span className="w-3 h-3 rounded-full bg-blue-500" />
187
+ {repo.language}
188
+ </span>
189
+ )}
190
+ {repo.stargazers_count !== undefined && (
191
+ <span className="flex items-center gap-1">
192
+ <Star className="w-3 h-3" />
193
+ {repo.stargazers_count}
194
+ </span>
195
+ )}
196
+ {repo.forks_count !== undefined && (
197
+ <span className="flex items-center gap-1">
198
+ <GitFork className="w-3 h-3" />
199
+ {repo.forks_count}
200
+ </span>
201
+ )}
202
+ </div>
203
+ </div>
204
+ </div>
205
+ </button>
206
+ ))}
207
+ </div>
208
+ )}
209
+ </div>
210
+ </div>
211
+ </div>
212
+ )
213
+ }
@@ -0,0 +1,128 @@
1
+ import { createServerFn } from '@tanstack/react-start'
2
+ import type { GitHubUser } from '../lib/github-auth'
3
+
4
+ export type OAuthTokenResult =
5
+ | { ok: true; token: string }
6
+ | { ok: false; error: string }
7
+
8
+ export type GitHubUserResult =
9
+ | { ok: true; user: GitHubUser }
10
+ | { ok: false; error: string }
11
+
12
+ export type GitHubRepoSearchResult =
13
+ | { ok: true; repos: Array<{ full_name: string; description: string | null; private: boolean; stargazers_count?: number; forks_count?: number; language?: string | null; updated_at?: string }> }
14
+ | { ok: false; error: string }
15
+
16
+ export const exchangeOAuthCode = createServerFn({ method: 'POST' })
17
+ .inputValidator((input: { code: string }) => input)
18
+ .handler(async ({ data: input }): Promise<OAuthTokenResult> => {
19
+ const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID || ''
20
+ const clientSecret = import.meta.env.VITE_GITHUB_CLIENT_SECRET || ''
21
+
22
+ if (!clientId || !clientSecret) {
23
+ return { ok: false, error: 'GitHub OAuth not configured' }
24
+ }
25
+
26
+ try {
27
+ const response = await fetch('https://github.com/login/oauth/access_token', {
28
+ method: 'POST',
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ 'Accept': 'application/json',
32
+ },
33
+ body: JSON.stringify({
34
+ client_id: clientId,
35
+ client_secret: clientSecret,
36
+ code: input.code,
37
+ }),
38
+ })
39
+
40
+ if (!response.ok) {
41
+ return { ok: false, error: `OAuth token exchange failed: ${response.statusText}` }
42
+ }
43
+
44
+ const data = await response.json() as { access_token?: string; error?: string }
45
+
46
+ if (data.error || !data.access_token) {
47
+ return { ok: false, error: data.error || 'No access token returned' }
48
+ }
49
+
50
+ return { ok: true, token: data.access_token }
51
+ } catch (err) {
52
+ return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }
53
+ }
54
+ })
55
+
56
+ export const fetchGitHubUser = createServerFn({ method: 'GET' })
57
+ .inputValidator((input: { token: string }) => input)
58
+ .handler(async ({ data: input }): Promise<GitHubUserResult> => {
59
+ try {
60
+ const response = await fetch('https://api.github.com/user', {
61
+ headers: {
62
+ 'Authorization': `token ${input.token}`,
63
+ 'User-Agent': 'acp-visualizer',
64
+ },
65
+ })
66
+
67
+ if (!response.ok) {
68
+ return { ok: false, error: `Failed to fetch user: ${response.statusText}` }
69
+ }
70
+
71
+ const data = await response.json() as { login: string; name: string | null; avatar_url: string }
72
+
73
+ return {
74
+ ok: true,
75
+ user: {
76
+ login: data.login,
77
+ name: data.name,
78
+ avatar_url: data.avatar_url,
79
+ },
80
+ }
81
+ } catch (err) {
82
+ return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }
83
+ }
84
+ })
85
+
86
+ export const searchGitHubRepos = createServerFn({ method: 'GET' })
87
+ .inputValidator((input: { token: string; query: string }) => input)
88
+ .handler(async ({ data: input }): Promise<GitHubRepoSearchResult> => {
89
+ try {
90
+ const response = await fetch(
91
+ `https://api.github.com/user/repos?per_page=100&sort=updated`,
92
+ {
93
+ headers: {
94
+ 'Authorization': `token ${input.token}`,
95
+ 'User-Agent': 'acp-visualizer',
96
+ },
97
+ }
98
+ )
99
+
100
+ if (!response.ok) {
101
+ return { ok: false, error: `Failed to fetch repos: ${response.statusText}` }
102
+ }
103
+
104
+ const data = await response.json() as Array<{
105
+ full_name: string
106
+ description: string | null
107
+ private: boolean
108
+ stargazers_count?: number
109
+ forks_count?: number
110
+ language?: string | null
111
+ updated_at?: string
112
+ }>
113
+
114
+ // Filter by query
115
+ const filtered = input.query
116
+ ? data.filter((r) =>
117
+ r.full_name.toLowerCase().includes(input.query.toLowerCase())
118
+ )
119
+ : data
120
+
121
+ return {
122
+ ok: true,
123
+ repos: filtered.slice(0, 20),
124
+ }
125
+ } catch (err) {
126
+ return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }
127
+ }
128
+ })