@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.
- 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 +62 -31
- 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 +13 -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.13.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, ChevronLeft, ChevronRight } 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 {
|
|
@@ -20,28 +22,50 @@ interface SidebarProps {
|
|
|
20
22
|
onProjectSelect?: (projectId: string) => void
|
|
21
23
|
onGitHubLoad?: (owner: string, repo: string) => Promise<void>
|
|
22
24
|
onClose?: () => void
|
|
25
|
+
isCollapsed?: boolean
|
|
26
|
+
onToggleCollapse?: () => void
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
export function Sidebar({ projects = [], currentProject = null, onProjectSelect, onGitHubLoad, onClose }: SidebarProps) {
|
|
29
|
+
export function Sidebar({ projects = [], currentProject = null, onProjectSelect, onGitHubLoad, onClose, isCollapsed = false, onToggleCollapse }: SidebarProps) {
|
|
26
30
|
const location = useRouterState({ select: (s) => s.location })
|
|
27
31
|
|
|
28
32
|
return (
|
|
29
|
-
<nav className=
|
|
33
|
+
<nav className={`w-full 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 transition-all duration-300 ${
|
|
34
|
+
isCollapsed ? 'lg:w-16' : 'lg:w-56'
|
|
35
|
+
}`}>
|
|
30
36
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between">
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<button
|
|
36
|
-
onClick={onClose}
|
|
37
|
-
className="lg:hidden p-1.5 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors"
|
|
38
|
-
aria-label="Close menu"
|
|
39
|
-
>
|
|
40
|
-
<X className="w-4 h-4 text-gray-700 dark:text-gray-300" />
|
|
41
|
-
</button>
|
|
37
|
+
{!isCollapsed && (
|
|
38
|
+
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300 tracking-wide">
|
|
39
|
+
ACP Visualizer
|
|
40
|
+
</span>
|
|
42
41
|
)}
|
|
42
|
+
<div className="flex items-center gap-2">
|
|
43
|
+
{onToggleCollapse && (
|
|
44
|
+
<button
|
|
45
|
+
onClick={onToggleCollapse}
|
|
46
|
+
className="hidden lg:block p-1.5 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors"
|
|
47
|
+
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
48
|
+
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
49
|
+
>
|
|
50
|
+
{isCollapsed ? (
|
|
51
|
+
<ChevronRight className="w-4 h-4 text-gray-700 dark:text-gray-300" />
|
|
52
|
+
) : (
|
|
53
|
+
<ChevronLeft className="w-4 h-4 text-gray-700 dark:text-gray-300" />
|
|
54
|
+
)}
|
|
55
|
+
</button>
|
|
56
|
+
)}
|
|
57
|
+
{onClose && (
|
|
58
|
+
<button
|
|
59
|
+
onClick={onClose}
|
|
60
|
+
className="lg:hidden p-1.5 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors"
|
|
61
|
+
aria-label="Close menu"
|
|
62
|
+
>
|
|
63
|
+
<X className="w-4 h-4 text-gray-700 dark:text-gray-300" />
|
|
64
|
+
</button>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
43
67
|
</div>
|
|
44
|
-
{projects.length > 1 && onProjectSelect && (
|
|
68
|
+
{projects.length > 1 && onProjectSelect && !isCollapsed && (
|
|
45
69
|
<div className="px-3 pt-3">
|
|
46
70
|
<ProjectSelector
|
|
47
71
|
projects={projects}
|
|
@@ -50,7 +74,7 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
|
|
|
50
74
|
/>
|
|
51
75
|
</div>
|
|
52
76
|
)}
|
|
53
|
-
<div className="
|
|
77
|
+
<div className="py-2">
|
|
54
78
|
{navItems.map((item) => {
|
|
55
79
|
const isActive =
|
|
56
80
|
item.to === '/'
|
|
@@ -61,32 +85,39 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
|
|
|
61
85
|
<Link
|
|
62
86
|
key={item.to}
|
|
63
87
|
to={item.to}
|
|
64
|
-
className={`flex items-center gap-3
|
|
88
|
+
className={`flex items-center gap-3 py-2 text-sm transition-colors ${
|
|
89
|
+
isCollapsed ? 'px-4 justify-center' : 'px-4'
|
|
90
|
+
} ${
|
|
65
91
|
isActive
|
|
66
92
|
? 'text-gray-900 dark:text-gray-100 bg-gray-200 dark:bg-gray-800/50'
|
|
67
93
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-200/50 dark:hover:bg-gray-800/30'
|
|
68
94
|
}`}
|
|
69
95
|
onClick={onClose}
|
|
96
|
+
title={isCollapsed ? item.label : undefined}
|
|
70
97
|
>
|
|
71
98
|
<item.icon className="w-4 h-4" />
|
|
72
|
-
{item.label}
|
|
99
|
+
{!isCollapsed && item.label}
|
|
73
100
|
</Link>
|
|
74
101
|
)
|
|
75
102
|
})}
|
|
76
103
|
</div>
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
{!isCollapsed && <hr className="border-gray-200 dark:border-gray-800" />}
|
|
105
|
+
{!isCollapsed && (
|
|
106
|
+
<div className="p-3 space-y-2">
|
|
107
|
+
<Link
|
|
108
|
+
to="/search"
|
|
109
|
+
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"
|
|
110
|
+
onClick={onClose}
|
|
111
|
+
>
|
|
112
|
+
<Search className="w-4 h-4" />
|
|
113
|
+
Search...
|
|
114
|
+
</Link>
|
|
115
|
+
<GitHubAuth />
|
|
116
|
+
{onGitHubLoad && (
|
|
117
|
+
<GitHubInput onLoad={onGitHubLoad} />
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
90
121
|
</nav>
|
|
91
122
|
)
|
|
92
123
|
}
|
|
@@ -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
|
+
}
|