@prmichaelsen/acp-visualizer 0.10.3 → 0.12.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.
- package/package.json +2 -1
- package/src/components/DocumentList.tsx +15 -1
- package/src/components/GitHubAuth.tsx +69 -0
- package/src/components/GitHubInput.tsx +116 -7
- package/src/components/SidePanel.tsx +68 -30
- package/src/components/Sidebar.tsx +8 -4
- package/src/contexts/SidePanelContext.tsx +13 -1
- package/src/lib/github-auth.ts +60 -0
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +10 -7
- package/src/routes/auth/github/callback.tsx +86 -0
- package/src/routes/github.tsx +213 -0
- package/src/services/github-oauth.service.ts +128 -0
- package/src/services/markdown.service.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prmichaelsen/acp-visualizer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Browser-based dashboard for visualizing ACP progress.yaml data",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"serve": "vite preview",
|
|
19
19
|
"deploy": "export $(cat .env.cloudflare.local | xargs) && VITE_HOSTED=true npm run build && wrangler deploy",
|
|
20
20
|
"tail": "export $(cat .env.cloudflare.local | xargs) && wrangler tail",
|
|
21
|
+
"wrangler": "export $(cat .env.cloudflare.local | xargs) && wrangler",
|
|
21
22
|
"test": "vitest",
|
|
22
23
|
"test:run": "vitest run",
|
|
23
24
|
"test:coverage": "vitest run --coverage",
|
|
@@ -15,13 +15,16 @@ export function DocumentList({ title, dirPath, baseTo, github }: DocumentListPro
|
|
|
15
15
|
const [files, setFiles] = useState<AgentFile[]>([])
|
|
16
16
|
const [error, setError] = useState<string | null>(null)
|
|
17
17
|
const [loading, setLoading] = useState(true)
|
|
18
|
+
const [directoryExists, setDirectoryExists] = useState(true)
|
|
18
19
|
|
|
19
20
|
useEffect(() => {
|
|
20
21
|
setLoading(true)
|
|
22
|
+
setDirectoryExists(true)
|
|
21
23
|
listAgentDirectory({ data: { dirPath, github } })
|
|
22
24
|
.then((result) => {
|
|
23
25
|
if (result.ok) {
|
|
24
26
|
setFiles(result.files)
|
|
27
|
+
setDirectoryExists(result.directoryExists !== false)
|
|
25
28
|
} else {
|
|
26
29
|
setError(result.error)
|
|
27
30
|
}
|
|
@@ -54,7 +57,18 @@ export function DocumentList({ title, dirPath, baseTo, github }: DocumentListPro
|
|
|
54
57
|
return (
|
|
55
58
|
<div className="p-6">
|
|
56
59
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
|
57
|
-
|
|
60
|
+
{!directoryExists && github ? (
|
|
61
|
+
<div className="bg-yellow-900/20 border border-yellow-800/30 rounded-lg p-4">
|
|
62
|
+
<p className="text-sm text-yellow-300 mb-2">
|
|
63
|
+
Directory <code className="text-yellow-400 bg-yellow-900/30 px-1.5 py-0.5 rounded">{dirPath}/</code> not found in this repository.
|
|
64
|
+
</p>
|
|
65
|
+
<p className="text-xs text-yellow-400/80">
|
|
66
|
+
This repository may not follow the standard ACP structure. You can create this directory and add .md files to populate this section.
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
) : (
|
|
70
|
+
<p className="text-sm text-gray-500">No documents found in <code className="text-gray-400">{dirPath}/</code></p>
|
|
71
|
+
)}
|
|
58
72
|
</div>
|
|
59
73
|
)
|
|
60
74
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { LogIn, LogOut } from 'lucide-react'
|
|
3
|
+
import { getStoredToken, getStoredUser, clearStoredToken, getGitHubAuthUrl } from '../lib/github-auth'
|
|
4
|
+
import type { GitHubUser } from '../lib/github-auth'
|
|
5
|
+
|
|
6
|
+
export function GitHubAuth() {
|
|
7
|
+
const [token, setToken] = useState<string | null>(null)
|
|
8
|
+
const [user, setUser] = useState<GitHubUser | null>(null)
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
setToken(getStoredToken())
|
|
12
|
+
setUser(getStoredUser())
|
|
13
|
+
}, [])
|
|
14
|
+
|
|
15
|
+
const handleLogin = () => {
|
|
16
|
+
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID
|
|
17
|
+
if (!clientId) {
|
|
18
|
+
alert('GitHub OAuth not configured')
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const redirectUri = `${window.location.origin}/auth/github/callback`
|
|
23
|
+
const authUrl = getGitHubAuthUrl(clientId, redirectUri)
|
|
24
|
+
window.location.href = authUrl
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const handleLogout = () => {
|
|
28
|
+
clearStoredToken()
|
|
29
|
+
setToken(null)
|
|
30
|
+
setUser(null)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (token && user) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex items-center gap-2 p-2 bg-gray-900 border border-gray-800 rounded-md">
|
|
36
|
+
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
37
|
+
{user.avatar_url && (
|
|
38
|
+
<img
|
|
39
|
+
src={user.avatar_url}
|
|
40
|
+
alt={user.login}
|
|
41
|
+
className="w-6 h-6 rounded-full"
|
|
42
|
+
/>
|
|
43
|
+
)}
|
|
44
|
+
<div className="flex-1 min-w-0">
|
|
45
|
+
<p className="text-xs font-medium text-gray-200 truncate">{user.name || user.login}</p>
|
|
46
|
+
<p className="text-xs text-gray-500 truncate">@{user.login}</p>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<button
|
|
50
|
+
onClick={handleLogout}
|
|
51
|
+
className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-gray-800 rounded transition-colors"
|
|
52
|
+
title="Sign out"
|
|
53
|
+
>
|
|
54
|
+
<LogOut className="w-4 h-4" />
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<button
|
|
62
|
+
onClick={handleLogin}
|
|
63
|
+
className="flex items-center justify-center gap-2 w-full px-3 py-2 text-sm text-gray-300 bg-gray-800 border border-gray-700 rounded-md hover:bg-gray-700 transition-colors"
|
|
64
|
+
>
|
|
65
|
+
<LogIn className="w-4 h-4" />
|
|
66
|
+
Sign in with GitHub
|
|
67
|
+
</button>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -1,19 +1,86 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
2
|
import { Github, Loader2 } from 'lucide-react'
|
|
3
|
+
import { getStoredToken } from '../lib/github-auth'
|
|
4
|
+
import { searchGitHubRepos } from '../services/github-oauth.service'
|
|
3
5
|
|
|
4
6
|
interface GitHubInputProps {
|
|
5
7
|
onLoad: (owner: string, repo: string) => Promise<void>
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
interface RepoSuggestion {
|
|
11
|
+
full_name: string
|
|
12
|
+
description: string | null
|
|
13
|
+
private: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export function GitHubInput({ onLoad }: GitHubInputProps) {
|
|
9
17
|
const [value, setValue] = useState('')
|
|
10
18
|
const [loading, setLoading] = useState(false)
|
|
11
19
|
const [error, setError] = useState<string | null>(null)
|
|
20
|
+
const [suggestions, setSuggestions] = useState<RepoSuggestion[]>([])
|
|
21
|
+
const [showSuggestions, setShowSuggestions] = useState(false)
|
|
22
|
+
const [searchLoading, setSearchLoading] = useState(false)
|
|
23
|
+
const [dropdownStyle, setDropdownStyle] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 })
|
|
24
|
+
const inputRef = useRef<HTMLDivElement>(null)
|
|
25
|
+
|
|
26
|
+
const token = getStoredToken()
|
|
27
|
+
|
|
28
|
+
// Update dropdown position when shown
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (showSuggestions && inputRef.current) {
|
|
31
|
+
const rect = inputRef.current.getBoundingClientRect()
|
|
32
|
+
setDropdownStyle({
|
|
33
|
+
top: rect.bottom + 4,
|
|
34
|
+
left: rect.left,
|
|
35
|
+
width: rect.width,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}, [showSuggestions])
|
|
39
|
+
|
|
40
|
+
// Search for repos when user types (authenticated only)
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!token || !value.trim() || value.includes('/')) {
|
|
43
|
+
setSuggestions([])
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const timeoutId = setTimeout(async () => {
|
|
48
|
+
setSearchLoading(true)
|
|
49
|
+
try {
|
|
50
|
+
const result = await searchGitHubRepos({ data: { token, query: value } })
|
|
51
|
+
if (result.ok) {
|
|
52
|
+
setSuggestions(result.repos)
|
|
53
|
+
setShowSuggestions(true)
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Silently fail - not critical
|
|
57
|
+
} finally {
|
|
58
|
+
setSearchLoading(false)
|
|
59
|
+
}
|
|
60
|
+
}, 300)
|
|
61
|
+
|
|
62
|
+
return () => clearTimeout(timeoutId)
|
|
63
|
+
}, [value, token])
|
|
12
64
|
|
|
13
|
-
|
|
65
|
+
// Close suggestions when clicking outside
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
68
|
+
if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
|
69
|
+
setShowSuggestions(false)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
73
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
const handleSubmit = async (repoFullName?: string) => {
|
|
14
77
|
setError(null)
|
|
78
|
+
setShowSuggestions(false)
|
|
79
|
+
|
|
80
|
+
const input = repoFullName || value.trim()
|
|
81
|
+
|
|
15
82
|
// Parse owner/repo from various formats
|
|
16
|
-
const cleaned =
|
|
83
|
+
const cleaned = input
|
|
17
84
|
.replace(/^https?:\/\/github\.com\//, '')
|
|
18
85
|
.replace(/\.git$/, '')
|
|
19
86
|
.replace(/\/$/, '')
|
|
@@ -28,6 +95,7 @@ export function GitHubInput({ onLoad }: GitHubInputProps) {
|
|
|
28
95
|
try {
|
|
29
96
|
await onLoad(parts[0], parts[1])
|
|
30
97
|
setValue('')
|
|
98
|
+
setSuggestions([])
|
|
31
99
|
} catch {
|
|
32
100
|
setError('Failed to load')
|
|
33
101
|
} finally {
|
|
@@ -36,7 +104,7 @@ export function GitHubInput({ onLoad }: GitHubInputProps) {
|
|
|
36
104
|
}
|
|
37
105
|
|
|
38
106
|
return (
|
|
39
|
-
<div>
|
|
107
|
+
<div ref={inputRef} className="relative">
|
|
40
108
|
<div className="flex gap-1">
|
|
41
109
|
<div className="relative flex-1">
|
|
42
110
|
<Github className="absolute left-2 top-2 w-4 h-4 text-gray-500" />
|
|
@@ -44,19 +112,60 @@ export function GitHubInput({ onLoad }: GitHubInputProps) {
|
|
|
44
112
|
type="text"
|
|
45
113
|
value={value}
|
|
46
114
|
onChange={(e) => { setValue(e.target.value); setError(null) }}
|
|
47
|
-
onKeyDown={(e) =>
|
|
48
|
-
|
|
115
|
+
onKeyDown={(e) => {
|
|
116
|
+
if (e.key === 'Enter') {
|
|
117
|
+
handleSubmit()
|
|
118
|
+
} else if (e.key === 'Escape') {
|
|
119
|
+
setShowSuggestions(false)
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
|
123
|
+
placeholder={token ? "Search your repos..." : "owner/repo"}
|
|
49
124
|
className="w-full bg-gray-900 border border-gray-800 rounded-md pl-8 pr-2 py-2 text-base text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-600 transition-colors"
|
|
50
125
|
/>
|
|
126
|
+
{searchLoading && (
|
|
127
|
+
<Loader2 className="absolute right-2 top-2 w-4 h-4 text-gray-500 animate-spin" />
|
|
128
|
+
)}
|
|
51
129
|
</div>
|
|
52
130
|
<button
|
|
53
|
-
onClick={handleSubmit}
|
|
131
|
+
onClick={() => handleSubmit()}
|
|
54
132
|
disabled={loading || !value.trim()}
|
|
55
133
|
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-md text-sm text-gray-300 hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
56
134
|
>
|
|
57
135
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Go'}
|
|
58
136
|
</button>
|
|
59
137
|
</div>
|
|
138
|
+
|
|
139
|
+
{/* Suggestions dropdown - using fixed positioning to avoid clipping */}
|
|
140
|
+
{showSuggestions && suggestions.length > 0 && (
|
|
141
|
+
<div
|
|
142
|
+
className="fixed z-[100] bg-gray-900 border border-gray-800 rounded-md shadow-lg max-h-60 overflow-y-auto"
|
|
143
|
+
style={{
|
|
144
|
+
top: `${dropdownStyle.top}px`,
|
|
145
|
+
left: `${dropdownStyle.left}px`,
|
|
146
|
+
width: `${dropdownStyle.width}px`,
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
{suggestions.map((repo) => (
|
|
150
|
+
<button
|
|
151
|
+
key={repo.full_name}
|
|
152
|
+
onClick={() => handleSubmit(repo.full_name)}
|
|
153
|
+
className="w-full text-left px-3 py-2 hover:bg-gray-800 transition-colors border-b border-gray-800 last:border-b-0"
|
|
154
|
+
>
|
|
155
|
+
<div className="flex items-center gap-2">
|
|
156
|
+
<span className="text-sm text-gray-200 font-medium">{repo.full_name}</span>
|
|
157
|
+
{repo.private && (
|
|
158
|
+
<span className="text-xs px-1.5 py-0.5 bg-yellow-900/30 text-yellow-500 rounded">Private</span>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
{repo.description && (
|
|
162
|
+
<p className="text-xs text-gray-500 mt-0.5 truncate">{repo.description}</p>
|
|
163
|
+
)}
|
|
164
|
+
</button>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
60
169
|
{error && (
|
|
61
170
|
<p className="text-xs text-red-400 mt-1">{error}</p>
|
|
62
171
|
)}
|
|
@@ -1,10 +1,48 @@
|
|
|
1
1
|
import { X } from 'lucide-react'
|
|
2
|
+
import { useRef, useState, useEffect } from 'react'
|
|
2
3
|
import { useSidePanel } from '../contexts/SidePanelContext'
|
|
3
4
|
import { MilestonePreview } from './MilestonePreview'
|
|
4
5
|
import { TaskPreview } from './TaskPreview'
|
|
5
6
|
|
|
6
7
|
export function SidePanel() {
|
|
7
|
-
const { content, isOpen, close } = useSidePanel()
|
|
8
|
+
const { content, isOpen, width, close, setWidth } = useSidePanel()
|
|
9
|
+
const [isResizing, setIsResizing] = useState(false)
|
|
10
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
11
|
+
|
|
12
|
+
// Handle resize drag
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!isResizing) return
|
|
15
|
+
|
|
16
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
17
|
+
if (!panelRef.current) return
|
|
18
|
+
const panelRect = panelRef.current.getBoundingClientRect()
|
|
19
|
+
const newWidth = panelRect.right - e.clientX
|
|
20
|
+
setWidth(newWidth)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const handleMouseUp = () => {
|
|
24
|
+
setIsResizing(false)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
28
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
32
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
33
|
+
}
|
|
34
|
+
}, [isResizing, setWidth])
|
|
35
|
+
|
|
36
|
+
// Prevent text selection while resizing
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (isResizing) {
|
|
39
|
+
document.body.style.userSelect = 'none'
|
|
40
|
+
document.body.style.cursor = 'ew-resize'
|
|
41
|
+
} else {
|
|
42
|
+
document.body.style.userSelect = ''
|
|
43
|
+
document.body.style.cursor = ''
|
|
44
|
+
}
|
|
45
|
+
}, [isResizing])
|
|
8
46
|
|
|
9
47
|
// Don't render at all until first open
|
|
10
48
|
if (!content && !isOpen) {
|
|
@@ -12,39 +50,39 @@ export function SidePanel() {
|
|
|
12
50
|
}
|
|
13
51
|
|
|
14
52
|
return (
|
|
15
|
-
|
|
16
|
-
{
|
|
53
|
+
<div
|
|
54
|
+
ref={panelRef}
|
|
55
|
+
className={`relative h-full bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800 shadow-lg overflow-hidden transition-all duration-300 flex-shrink-0 ${
|
|
56
|
+
isOpen ? 'opacity-100' : 'opacity-0 w-0 border-l-0'
|
|
57
|
+
}`}
|
|
58
|
+
style={{ width: isOpen ? `${width}px` : '0px' }}
|
|
59
|
+
>
|
|
60
|
+
{/* Resize Handle */}
|
|
17
61
|
<div
|
|
18
|
-
className={`
|
|
19
|
-
|
|
62
|
+
className={`absolute top-0 left-0 bottom-0 w-1 cursor-ew-resize hover:bg-blue-500 hover:w-1 transition-all z-20 ${
|
|
63
|
+
isResizing ? 'bg-blue-500 w-1' : 'bg-transparent'
|
|
20
64
|
}`}
|
|
21
|
-
|
|
65
|
+
onMouseDown={() => setIsResizing(true)}
|
|
66
|
+
aria-label="Resize panel"
|
|
22
67
|
/>
|
|
23
68
|
|
|
24
|
-
{/*
|
|
25
|
-
<div
|
|
26
|
-
className=
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
</div>
|
|
41
|
-
|
|
42
|
-
{/* Content */}
|
|
43
|
-
<div className="p-6">
|
|
44
|
-
{content?.type === 'milestone' && <MilestonePreview milestoneId={content.id} />}
|
|
45
|
-
{content?.type === 'task' && <TaskPreview taskId={content.id} />}
|
|
46
|
-
</div>
|
|
69
|
+
{/* Header */}
|
|
70
|
+
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-4 flex items-center justify-between z-10">
|
|
71
|
+
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Preview</h2>
|
|
72
|
+
<button
|
|
73
|
+
onClick={close}
|
|
74
|
+
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
75
|
+
aria-label="Close panel"
|
|
76
|
+
>
|
|
77
|
+
<X className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Content */}
|
|
82
|
+
<div className="p-6 overflow-auto h-[calc(100%-57px)]">
|
|
83
|
+
{content?.type === 'milestone' && <MilestonePreview milestoneId={content.id} />}
|
|
84
|
+
{content?.type === 'task' && <TaskPreview taskId={content.id} />}
|
|
47
85
|
</div>
|
|
48
|
-
|
|
86
|
+
</div>
|
|
49
87
|
)
|
|
50
88
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Link, useRouterState } from '@tanstack/react-router'
|
|
2
|
-
import { LayoutDashboard, Flag, CheckSquare, Clock, Search, PenTool, Puzzle, FileBarChart, X } from 'lucide-react'
|
|
2
|
+
import { LayoutDashboard, Flag, CheckSquare, Clock, Search, PenTool, Puzzle, FileBarChart, Github, X } from 'lucide-react'
|
|
3
3
|
import { ProjectSelector } from './ProjectSelector'
|
|
4
4
|
import { GitHubInput } from './GitHubInput'
|
|
5
|
+
import { GitHubAuth } from './GitHubAuth'
|
|
5
6
|
import type { AcpProject } from '../services/projects.service'
|
|
6
7
|
|
|
7
8
|
const navItems = [
|
|
@@ -12,6 +13,7 @@ const navItems = [
|
|
|
12
13
|
{ to: '/designs' as const, icon: PenTool, label: 'Designs' },
|
|
13
14
|
{ to: '/patterns' as const, icon: Puzzle, label: 'Patterns' },
|
|
14
15
|
{ to: '/reports' as const, icon: FileBarChart, label: 'Reports' },
|
|
16
|
+
{ to: '/github' as const, icon: Github, label: 'GitHub' },
|
|
15
17
|
]
|
|
16
18
|
|
|
17
19
|
interface SidebarProps {
|
|
@@ -26,7 +28,7 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
|
|
|
26
28
|
const location = useRouterState({ select: (s) => s.location })
|
|
27
29
|
|
|
28
30
|
return (
|
|
29
|
-
<nav className="w-full lg:w-56 h-auto max-h-[80vh] lg:h-full border-t lg:border-t-0 lg:border-r border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950 flex flex-col shrink-0 rounded-t-2xl lg:rounded-none overflow-
|
|
31
|
+
<nav className="w-full lg:w-56 h-auto max-h-[80vh] lg:h-full border-t lg:border-t-0 lg:border-r border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950 flex flex-col shrink-0 rounded-t-2xl lg:rounded-none overflow-y-auto">
|
|
30
32
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between">
|
|
31
33
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300 tracking-wide">
|
|
32
34
|
ACP Visualizer
|
|
@@ -50,7 +52,7 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
|
|
|
50
52
|
/>
|
|
51
53
|
</div>
|
|
52
54
|
)}
|
|
53
|
-
<div className="
|
|
55
|
+
<div className="py-2">
|
|
54
56
|
{navItems.map((item) => {
|
|
55
57
|
const isActive =
|
|
56
58
|
item.to === '/'
|
|
@@ -74,7 +76,8 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
|
|
|
74
76
|
)
|
|
75
77
|
})}
|
|
76
78
|
</div>
|
|
77
|
-
<
|
|
79
|
+
<hr className="border-gray-200 dark:border-gray-800" />
|
|
80
|
+
<div className="p-3 space-y-2">
|
|
78
81
|
<Link
|
|
79
82
|
to="/search"
|
|
80
83
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-500 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-800 rounded-md hover:text-gray-900 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-600 transition-colors"
|
|
@@ -83,6 +86,7 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
|
|
|
83
86
|
<Search className="w-4 h-4" />
|
|
84
87
|
Search...
|
|
85
88
|
</Link>
|
|
89
|
+
<GitHubAuth />
|
|
86
90
|
{onGitHubLoad && (
|
|
87
91
|
<GitHubInput onLoad={onGitHubLoad} />
|
|
88
92
|
)}
|
|
@@ -8,16 +8,23 @@ type PanelContent =
|
|
|
8
8
|
interface SidePanelContextValue {
|
|
9
9
|
content: PanelContent
|
|
10
10
|
isOpen: boolean
|
|
11
|
+
width: number
|
|
11
12
|
openMilestone: (id: string) => void
|
|
12
13
|
openTask: (id: string) => void
|
|
13
14
|
close: () => void
|
|
15
|
+
setWidth: (width: number) => void
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
const SidePanelContext = createContext<SidePanelContextValue | undefined>(undefined)
|
|
17
19
|
|
|
20
|
+
const MIN_WIDTH = 300
|
|
21
|
+
const MAX_WIDTH = 800
|
|
22
|
+
const DEFAULT_WIDTH = 500
|
|
23
|
+
|
|
18
24
|
export function SidePanelProvider({ children }: { children: ReactNode }) {
|
|
19
25
|
const [content, setContent] = useState<PanelContent>(null)
|
|
20
26
|
const [isOpen, setIsOpen] = useState(false)
|
|
27
|
+
const [width, setWidthState] = useState(DEFAULT_WIDTH)
|
|
21
28
|
|
|
22
29
|
const openMilestone = (id: string) => {
|
|
23
30
|
setContent({ type: 'milestone', id })
|
|
@@ -34,8 +41,13 @@ export function SidePanelProvider({ children }: { children: ReactNode }) {
|
|
|
34
41
|
setTimeout(() => setContent(null), 300) // Wait for animation
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
const setWidth = (newWidth: number) => {
|
|
45
|
+
const clampedWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth))
|
|
46
|
+
setWidthState(clampedWidth)
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
return (
|
|
38
|
-
<SidePanelContext.Provider value={{ content, isOpen, openMilestone, openTask, close }}>
|
|
50
|
+
<SidePanelContext.Provider value={{ content, isOpen, width, openMilestone, openTask, close, setWidth }}>
|
|
39
51
|
{children}
|
|
40
52
|
</SidePanelContext.Provider>
|
|
41
53
|
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const TOKEN_KEY = 'github_access_token'
|
|
2
|
+
const USER_KEY = 'github_user'
|
|
3
|
+
|
|
4
|
+
export interface GitHubUser {
|
|
5
|
+
login: string
|
|
6
|
+
name: string | null
|
|
7
|
+
avatar_url: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getStoredToken(): string | null {
|
|
11
|
+
if (typeof window === 'undefined') return null
|
|
12
|
+
return localStorage.getItem(TOKEN_KEY)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function setStoredToken(token: string) {
|
|
16
|
+
if (typeof window === 'undefined') return
|
|
17
|
+
localStorage.setItem(TOKEN_KEY, token)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function clearStoredToken() {
|
|
21
|
+
if (typeof window === 'undefined') return
|
|
22
|
+
localStorage.removeItem(TOKEN_KEY)
|
|
23
|
+
localStorage.removeItem(USER_KEY)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getStoredUser(): GitHubUser | null {
|
|
27
|
+
if (typeof window === 'undefined') return null
|
|
28
|
+
const stored = localStorage.getItem(USER_KEY)
|
|
29
|
+
if (!stored) return null
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(stored)
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setStoredUser(user: GitHubUser) {
|
|
38
|
+
if (typeof window === 'undefined') return
|
|
39
|
+
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getGitHubAuthUrl(clientId: string, redirectUri: string): string {
|
|
43
|
+
const state = crypto.randomUUID()
|
|
44
|
+
sessionStorage.setItem('github_oauth_state', state)
|
|
45
|
+
|
|
46
|
+
const params = new URLSearchParams({
|
|
47
|
+
client_id: clientId,
|
|
48
|
+
redirect_uri: redirectUri,
|
|
49
|
+
scope: 'repo',
|
|
50
|
+
state,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return `https://github.com/login/oauth/authorize?${params.toString()}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateOAuthState(receivedState: string): boolean {
|
|
57
|
+
const storedState = sessionStorage.getItem('github_oauth_state')
|
|
58
|
+
sessionStorage.removeItem('github_oauth_state')
|
|
59
|
+
return storedState === receivedState
|
|
60
|
+
}
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -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)
|
package/src/routes/__root.tsx
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { HeadContent, Scripts, createRootRoute, Outlet
|
|
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) {
|
|
@@ -120,7 +121,8 @@ function RootLayout() {
|
|
|
120
121
|
const repoParam = getRepoFromUrl()
|
|
121
122
|
// Repo param takes precedence over default local data
|
|
122
123
|
if (repoParam) {
|
|
123
|
-
|
|
124
|
+
const token = getStoredToken()
|
|
125
|
+
void (fetchGitHubProgress({ data: { ...repoParam, token: token || undefined } }) as Promise<GitHubResult>).then((result) => {
|
|
124
126
|
if (result.ok) {
|
|
125
127
|
setProgressData(result.data)
|
|
126
128
|
setCurrentProject(`${repoParam.owner}/${repoParam.repo}`)
|
|
@@ -130,7 +132,8 @@ function RootLayout() {
|
|
|
130
132
|
}, [initialLoadDone])
|
|
131
133
|
|
|
132
134
|
const handleGitHubLoad = useCallback(async (owner: string, repo: string) => {
|
|
133
|
-
const
|
|
135
|
+
const token = getStoredToken()
|
|
136
|
+
const result = await (fetchGitHubProgress({ data: { owner, repo, token: token || undefined } }) as Promise<GitHubResult>)
|
|
134
137
|
if (result.ok) {
|
|
135
138
|
setProgressData(result.data)
|
|
136
139
|
setCurrentProject(`${owner}/${repo}`)
|
|
@@ -144,7 +147,7 @@ function RootLayout() {
|
|
|
144
147
|
try {
|
|
145
148
|
const path = await getProjectProgressPath({ data: { projectId } })
|
|
146
149
|
if (path) {
|
|
147
|
-
const result = await getProgressData({ data: { path } })
|
|
150
|
+
const result = await (getProgressData({ data: { path } }) as Promise<ProgressResult>)
|
|
148
151
|
if (result.ok) {
|
|
149
152
|
setProgressData(result.data)
|
|
150
153
|
setCurrentProject(projectId)
|
|
@@ -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
|
+
})
|
|
@@ -124,7 +124,7 @@ export type AgentFile = {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
export type ListDirResult =
|
|
127
|
-
| { ok: true; files: AgentFile[] }
|
|
127
|
+
| { ok: true; files: AgentFile[]; directoryExists?: boolean }
|
|
128
128
|
| { ok: false; files: []; error: string }
|
|
129
129
|
|
|
130
130
|
export const listAgentDirectory = createServerFn({ method: 'GET' })
|
|
@@ -197,7 +197,7 @@ async function listDirFromGitHub(
|
|
|
197
197
|
|
|
198
198
|
if (!response.ok) {
|
|
199
199
|
if (response.status === 404) {
|
|
200
|
-
return { ok: true, files: [] }
|
|
200
|
+
return { ok: true, files: [], directoryExists: false }
|
|
201
201
|
}
|
|
202
202
|
return { ok: false, files: [], error: `GitHub returned ${response.status}` }
|
|
203
203
|
}
|