@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prmichaelsen/acp-visualizer",
3
- "version": "0.10.3",
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
- <p className="text-sm text-gray-500">No documents found in <code className="text-gray-400">{dirPath}/</code></p>
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
- const handleSubmit = async () => {
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 = value.trim()
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) => e.key === 'Enter' && handleSubmit()}
48
- placeholder="owner/repo"
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
- {/* Backdrop */}
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={`fixed inset-0 bg-black/30 backdrop-blur-sm z-40 transition-opacity duration-300 ${
19
- isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
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
- onClick={close}
65
+ onMouseDown={() => setIsResizing(true)}
66
+ aria-label="Resize panel"
22
67
  />
23
68
 
24
- {/* Panel */}
25
- <div
26
- className={`fixed top-0 right-0 h-full w-full lg:max-w-2xl bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800 shadow-2xl z-50 overflow-auto transition-transform duration-300 ${
27
- isOpen ? 'translate-x-0' : 'translate-x-full'
28
- }`}
29
- >
30
- {/* Header */}
31
- <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">
32
- <h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Preview</h2>
33
- <button
34
- onClick={close}
35
- className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
36
- aria-label="Close panel"
37
- >
38
- <X className="w-4 h-4 text-gray-600 dark:text-gray-400" />
39
- </button>
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="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-hidden">
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
- <span className="text-sm font-semibold text-gray-700 dark:text-gray-300 tracking-wide">
32
- ACP Visualizer
33
- </span>
34
- {onClose && (
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="flex-1 py-2 overflow-y-auto">
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 px-4 py-2 text-sm transition-colors ${
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
- <div className="p-3 border-t border-gray-200 dark:border-gray-800 space-y-2">
78
- <Link
79
- to="/search"
80
- 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"
81
- onClick={onClose}
82
- >
83
- <Search className="w-4 h-4" />
84
- Search...
85
- </Link>
86
- {onGitHubLoad && (
87
- <GitHubInput onLoad={onGitHubLoad} />
88
- )}
89
- </div>
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
+ }