@prmichaelsen/acp-visualizer 0.8.2 → 0.9.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 +1 -1
- package/src/components/Header.tsx +23 -6
- package/src/components/MilestoneKanban.tsx +12 -8
- package/src/components/MilestonePreview.tsx +162 -0
- package/src/components/MilestoneTable.tsx +12 -8
- package/src/components/MilestoneTree.tsx +17 -13
- package/src/components/PreviewButton.tsx +33 -0
- package/src/components/SidePanel.tsx +45 -0
- package/src/components/Sidebar.tsx +7 -7
- package/src/components/TaskList.tsx +7 -5
- package/src/components/TaskPreview.tsx +147 -0
- package/src/contexts/SidePanelContext.tsx +50 -0
- package/src/lib/useTheme.ts +27 -0
- package/src/routes/__root.tsx +14 -9
- package/src/styles.css +2 -2
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { Moon, Sun } from 'lucide-react'
|
|
1
2
|
import { StatusBadge } from './StatusBadge'
|
|
2
3
|
import { ProgressBar } from './ProgressBar'
|
|
4
|
+
import { useTheme } from '../lib/useTheme'
|
|
3
5
|
import type { ProgressData } from '../lib/types'
|
|
4
6
|
|
|
5
7
|
interface HeaderProps {
|
|
@@ -7,16 +9,31 @@ interface HeaderProps {
|
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
export function Header({ data }: HeaderProps) {
|
|
12
|
+
const { theme, toggleTheme } = useTheme()
|
|
13
|
+
|
|
10
14
|
if (!data) return null
|
|
11
15
|
|
|
12
16
|
return (
|
|
13
|
-
<header className="h-14 border-b border-gray-800 flex items-center px-6 gap-4 shrink-0">
|
|
14
|
-
<h1 className="text-sm font-medium text-gray-200">{data.project.name}</h1>
|
|
15
|
-
<span className="text-xs text-gray-500 font-mono">v{data.project.version}</span>
|
|
17
|
+
<header className="h-14 border-b border-gray-200 dark:border-gray-800 flex items-center px-6 gap-4 shrink-0 bg-white dark:bg-gray-950">
|
|
18
|
+
<h1 className="text-sm font-medium text-gray-900 dark:text-gray-200">{data.project.name}</h1>
|
|
19
|
+
<span className="text-xs text-gray-500 dark:text-gray-500 font-mono">v{data.project.version}</span>
|
|
16
20
|
<StatusBadge status={data.project.status} />
|
|
17
|
-
<div className="ml-auto flex items-center gap-
|
|
18
|
-
<
|
|
19
|
-
|
|
21
|
+
<div className="ml-auto flex items-center gap-4">
|
|
22
|
+
<div className="flex items-center gap-3 w-48">
|
|
23
|
+
<ProgressBar value={data.progress.overall} size="sm" />
|
|
24
|
+
<span className="text-xs text-gray-600 dark:text-gray-400 font-mono">{data.progress.overall}%</span>
|
|
25
|
+
</div>
|
|
26
|
+
<button
|
|
27
|
+
onClick={toggleTheme}
|
|
28
|
+
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
29
|
+
aria-label="Toggle theme"
|
|
30
|
+
>
|
|
31
|
+
{theme === 'dark' ? (
|
|
32
|
+
<Moon className="w-4 h-4 text-gray-400" />
|
|
33
|
+
) : (
|
|
34
|
+
<Sun className="w-4 h-4 text-gray-600" />
|
|
35
|
+
)}
|
|
36
|
+
</button>
|
|
20
37
|
</div>
|
|
21
38
|
</header>
|
|
22
39
|
)
|
|
@@ -2,6 +2,7 @@ import { Link } from '@tanstack/react-router'
|
|
|
2
2
|
import { StatusBadge } from './StatusBadge'
|
|
3
3
|
import { PriorityBadge } from './PriorityBadge'
|
|
4
4
|
import { ProgressBar } from './ProgressBar'
|
|
5
|
+
import { PreviewButton } from './PreviewButton'
|
|
5
6
|
import { TaskList } from './TaskList'
|
|
6
7
|
import type { Milestone, Task, Status } from '../lib/types'
|
|
7
8
|
import { useState } from 'react'
|
|
@@ -29,15 +30,18 @@ function KanbanCard({
|
|
|
29
30
|
const [expanded, setExpanded] = useState(false)
|
|
30
31
|
|
|
31
32
|
return (
|
|
32
|
-
<div className="bg-gray-900/50 border border-gray-800 rounded-lg p-3 hover:border-gray-700 transition-colors">
|
|
33
|
+
<div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-lg p-3 hover:border-gray-300 dark:hover:border-gray-700 transition-colors group">
|
|
33
34
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
<div className="flex items-center gap-2 flex-1">
|
|
36
|
+
<Link
|
|
37
|
+
to="/milestones/$milestoneId"
|
|
38
|
+
params={{ milestoneId: milestone.id }}
|
|
39
|
+
className="text-sm font-medium leading-tight text-gray-900 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
|
40
|
+
>
|
|
41
|
+
{milestone.name}
|
|
42
|
+
</Link>
|
|
43
|
+
<PreviewButton type="milestone" id={milestone.id} />
|
|
44
|
+
</div>
|
|
41
45
|
<PriorityBadge priority={milestone.priority} />
|
|
42
46
|
</div>
|
|
43
47
|
<div className="flex items-center gap-2 mb-2">
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Link } from '@tanstack/react-router'
|
|
2
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
3
|
+
import { ExternalLink } from 'lucide-react'
|
|
4
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
5
|
+
import { DetailHeader } from './DetailHeader'
|
|
6
|
+
import { ProgressBar } from './ProgressBar'
|
|
7
|
+
import { StatusDot } from './StatusDot'
|
|
8
|
+
import { PriorityBadge } from './PriorityBadge'
|
|
9
|
+
import { MarkdownContent, buildLinkMap } from './MarkdownContent'
|
|
10
|
+
import { getMarkdownContent, resolveMilestoneFile } from '../services/markdown.service'
|
|
11
|
+
import type { MarkdownResult, ResolveFileResult } from '../services/markdown.service'
|
|
12
|
+
|
|
13
|
+
interface MilestonePreviewProps {
|
|
14
|
+
milestoneId: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getGitHubParams(): { owner: string; repo: string } | undefined {
|
|
18
|
+
if (typeof window === 'undefined') return undefined
|
|
19
|
+
const params = new URLSearchParams(window.location.search)
|
|
20
|
+
const repo = params.get('repo')
|
|
21
|
+
if (!repo) return undefined
|
|
22
|
+
const parts = repo.split('/')
|
|
23
|
+
if (parts.length < 2) return undefined
|
|
24
|
+
return { owner: parts[0], repo: parts[1] }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function MilestonePreview({ milestoneId }: MilestonePreviewProps) {
|
|
28
|
+
const data = useProgressData()
|
|
29
|
+
const [markdown, setMarkdown] = useState<string | null>(null)
|
|
30
|
+
const [markdownError, setMarkdownError] = useState<string | null>(null)
|
|
31
|
+
const [markdownFilePath, setMarkdownFilePath] = useState<string | null>(null)
|
|
32
|
+
const [loading, setLoading] = useState(true)
|
|
33
|
+
|
|
34
|
+
const milestone = data?.milestones.find((m) => m.id === milestoneId)
|
|
35
|
+
const tasks = data?.tasks[milestoneId] || []
|
|
36
|
+
const linkMap = useMemo(() => (data ? buildLinkMap(data) : {}), [data])
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!milestoneId) return
|
|
40
|
+
|
|
41
|
+
setLoading(true)
|
|
42
|
+
setMarkdown(null)
|
|
43
|
+
setMarkdownError(null)
|
|
44
|
+
setMarkdownFilePath(null)
|
|
45
|
+
|
|
46
|
+
const github = getGitHubParams()
|
|
47
|
+
|
|
48
|
+
resolveMilestoneFile({ data: { milestoneId, github } })
|
|
49
|
+
.then((resolveResult: ResolveFileResult) => {
|
|
50
|
+
if (!resolveResult.ok) {
|
|
51
|
+
setMarkdownError(resolveResult.error)
|
|
52
|
+
setLoading(false)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setMarkdownFilePath(resolveResult.filePath)
|
|
57
|
+
return getMarkdownContent({ data: { filePath: resolveResult.filePath, github } })
|
|
58
|
+
.then((mdResult: MarkdownResult) => {
|
|
59
|
+
if (mdResult.ok) {
|
|
60
|
+
setMarkdown(mdResult.content)
|
|
61
|
+
} else {
|
|
62
|
+
setMarkdownError(mdResult.error)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
.catch((err: Error) => {
|
|
67
|
+
setMarkdownError(err.message)
|
|
68
|
+
})
|
|
69
|
+
.finally(() => {
|
|
70
|
+
setLoading(false)
|
|
71
|
+
})
|
|
72
|
+
}, [milestoneId])
|
|
73
|
+
|
|
74
|
+
if (!data || !milestone) {
|
|
75
|
+
return (
|
|
76
|
+
<div className="text-center py-8">
|
|
77
|
+
<p className="text-gray-500 dark:text-gray-400 text-sm">Milestone not found: {milestoneId}</p>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const fields = [
|
|
83
|
+
...(milestone.started ? [{ label: 'Started', value: milestone.started }] : []),
|
|
84
|
+
...(milestone.completed ? [{ label: 'Completed', value: milestone.completed }] : []),
|
|
85
|
+
{ label: 'Est', value: `${milestone.estimated_weeks} week${milestone.estimated_weeks === '1' ? '' : 's'}` },
|
|
86
|
+
{ label: 'Tasks', value: `${milestone.tasks_completed}/${milestone.tasks_total}` },
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div>
|
|
91
|
+
<div className="flex items-start justify-between mb-4">
|
|
92
|
+
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{milestone.name}</h1>
|
|
93
|
+
<Link
|
|
94
|
+
to="/milestones/$milestoneId"
|
|
95
|
+
params={{ milestoneId }}
|
|
96
|
+
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
97
|
+
title="Open full view"
|
|
98
|
+
>
|
|
99
|
+
<ExternalLink className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
100
|
+
</Link>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div className="flex items-center gap-3 mb-4">
|
|
104
|
+
<div className="flex-1 max-w-xs">
|
|
105
|
+
<ProgressBar value={milestone.progress} size="sm" />
|
|
106
|
+
</div>
|
|
107
|
+
<span className="text-xs text-gray-600 dark:text-gray-500">{milestone.progress}%</span>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div className="flex items-center gap-2 mb-4">
|
|
111
|
+
<PriorityBadge priority={milestone.priority} />
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<DetailHeader status={milestone.status} fields={fields} />
|
|
115
|
+
|
|
116
|
+
{milestone.notes && (
|
|
117
|
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">{milestone.notes}</p>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{/* Markdown content */}
|
|
121
|
+
{loading ? (
|
|
122
|
+
<p className="text-sm text-gray-600 dark:text-gray-500">Loading document...</p>
|
|
123
|
+
) : markdown ? (
|
|
124
|
+
<div className="prose-sm">
|
|
125
|
+
<MarkdownContent content={markdown} basePath={markdownFilePath ?? undefined} linkMap={linkMap} />
|
|
126
|
+
</div>
|
|
127
|
+
) : markdownError ? (
|
|
128
|
+
<div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-xl p-4 text-sm text-gray-600 dark:text-gray-500">
|
|
129
|
+
No document found — {markdownError}
|
|
130
|
+
</div>
|
|
131
|
+
) : null}
|
|
132
|
+
|
|
133
|
+
{/* Task list */}
|
|
134
|
+
{tasks.length > 0 && (
|
|
135
|
+
<div className="mt-8">
|
|
136
|
+
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-400 uppercase tracking-wider mb-3">
|
|
137
|
+
Tasks
|
|
138
|
+
</h2>
|
|
139
|
+
<div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-xl divide-y divide-gray-200 dark:divide-gray-800">
|
|
140
|
+
{tasks.map((task) => (
|
|
141
|
+
<Link
|
|
142
|
+
key={task.id}
|
|
143
|
+
to="/tasks/$taskId"
|
|
144
|
+
params={{ taskId: task.id }}
|
|
145
|
+
className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gray-200/50 dark:hover:bg-gray-800/50 transition-colors first:rounded-t-xl last:rounded-b-xl"
|
|
146
|
+
>
|
|
147
|
+
<StatusDot status={task.status} />
|
|
148
|
+
<span className={task.status === 'completed' ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-200'}>
|
|
149
|
+
{task.name}
|
|
150
|
+
</span>
|
|
151
|
+
<PriorityBadge priority={task.priority} />
|
|
152
|
+
{task.estimated_hours && (
|
|
153
|
+
<span className="text-xs text-gray-600 dark:text-gray-600 ml-auto">{task.estimated_hours}h</span>
|
|
154
|
+
)}
|
|
155
|
+
</Link>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -12,6 +12,7 @@ import { ChevronDown, ChevronRight, ArrowUpDown } from 'lucide-react'
|
|
|
12
12
|
import { StatusBadge } from './StatusBadge'
|
|
13
13
|
import { PriorityBadge } from './PriorityBadge'
|
|
14
14
|
import { ProgressBar } from './ProgressBar'
|
|
15
|
+
import { PreviewButton } from './PreviewButton'
|
|
15
16
|
import { TaskList } from './TaskList'
|
|
16
17
|
import type { Milestone, Task } from '../lib/types'
|
|
17
18
|
|
|
@@ -54,14 +55,17 @@ export function MilestoneTable({ milestones, tasks }: MilestoneTableProps) {
|
|
|
54
55
|
columnHelper.accessor('name', {
|
|
55
56
|
header: 'Milestone',
|
|
56
57
|
cell: (info) => (
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
<div className="flex items-center gap-2 group">
|
|
59
|
+
<Link
|
|
60
|
+
to="/milestones/$milestoneId"
|
|
61
|
+
params={{ milestoneId: info.row.original.id }}
|
|
62
|
+
className="text-sm font-medium text-gray-900 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
|
63
|
+
onClick={(e) => e.stopPropagation()}
|
|
64
|
+
>
|
|
65
|
+
{info.getValue()}
|
|
66
|
+
</Link>
|
|
67
|
+
<PreviewButton type="milestone" id={info.row.original.id} />
|
|
68
|
+
</div>
|
|
65
69
|
),
|
|
66
70
|
}),
|
|
67
71
|
columnHelper.accessor('status', {
|
|
@@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react'
|
|
|
4
4
|
import { StatusBadge } from './StatusBadge'
|
|
5
5
|
import { PriorityBadge } from './PriorityBadge'
|
|
6
6
|
import { ProgressBar } from './ProgressBar'
|
|
7
|
+
import { PreviewButton } from './PreviewButton'
|
|
7
8
|
import { TaskList } from './TaskList'
|
|
8
9
|
import { useCollapse } from '../lib/useCollapse'
|
|
9
10
|
import type { Milestone, Task } from '../lib/types'
|
|
@@ -27,30 +28,33 @@ function MilestoneTreeRow({
|
|
|
27
28
|
const collapse = useCollapse(expanded)
|
|
28
29
|
|
|
29
30
|
return (
|
|
30
|
-
<div className="border-b border-gray-800/50">
|
|
31
|
+
<div className="border-b border-gray-200 dark:border-gray-800/50 group">
|
|
31
32
|
<button
|
|
32
33
|
onClick={onToggle}
|
|
33
|
-
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition-colors text-left"
|
|
34
|
+
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-200/50 dark:hover:bg-gray-800/30 transition-colors text-left"
|
|
34
35
|
>
|
|
35
36
|
{expanded ? (
|
|
36
|
-
<ChevronDown className="w-4 h-4 text-gray-500 shrink-0" />
|
|
37
|
+
<ChevronDown className="w-4 h-4 text-gray-500 dark:text-gray-500 shrink-0" />
|
|
37
38
|
) : (
|
|
38
|
-
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
|
|
39
|
+
<ChevronRight className="w-4 h-4 text-gray-500 dark:text-gray-500 shrink-0" />
|
|
39
40
|
)}
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
<div className="flex items-center gap-2 flex-1">
|
|
42
|
+
<Link
|
|
43
|
+
to="/milestones/$milestoneId"
|
|
44
|
+
params={{ milestoneId: milestone.id }}
|
|
45
|
+
className="text-sm font-medium text-gray-900 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
|
46
|
+
onClick={(e) => e.stopPropagation()}
|
|
47
|
+
>
|
|
48
|
+
{milestone.name}
|
|
49
|
+
</Link>
|
|
50
|
+
<PreviewButton type="milestone" id={milestone.id} />
|
|
51
|
+
</div>
|
|
48
52
|
<StatusBadge status={milestone.status} />
|
|
49
53
|
<PriorityBadge priority={milestone.priority} />
|
|
50
54
|
<div className="w-20">
|
|
51
55
|
<ProgressBar value={milestone.progress} size="sm" />
|
|
52
56
|
</div>
|
|
53
|
-
<span className="text-xs text-gray-500 font-mono w-12 text-right">
|
|
57
|
+
<span className="text-xs text-gray-500 dark:text-gray-500 font-mono w-12 text-right">
|
|
54
58
|
{milestone.tasks_completed}/{milestone.tasks_total}
|
|
55
59
|
</span>
|
|
56
60
|
</button>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Eye } from 'lucide-react'
|
|
2
|
+
import { useSidePanel } from '../contexts/SidePanelContext'
|
|
3
|
+
|
|
4
|
+
interface PreviewButtonProps {
|
|
5
|
+
type: 'milestone' | 'task'
|
|
6
|
+
id: string
|
|
7
|
+
className?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function PreviewButton({ type, id, className = '' }: PreviewButtonProps) {
|
|
11
|
+
const { openMilestone, openTask } = useSidePanel()
|
|
12
|
+
|
|
13
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
14
|
+
e.preventDefault()
|
|
15
|
+
e.stopPropagation()
|
|
16
|
+
if (type === 'milestone') {
|
|
17
|
+
openMilestone(id)
|
|
18
|
+
} else {
|
|
19
|
+
openTask(id)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<button
|
|
25
|
+
onClick={handleClick}
|
|
26
|
+
className={`p-1.5 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors opacity-0 group-hover:opacity-100 ${className}`}
|
|
27
|
+
title={`Preview ${type}`}
|
|
28
|
+
aria-label={`Preview ${type}`}
|
|
29
|
+
>
|
|
30
|
+
<Eye className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
|
|
31
|
+
</button>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { X } from 'lucide-react'
|
|
2
|
+
import { useSidePanel } from '../contexts/SidePanelContext'
|
|
3
|
+
import { MilestonePreview } from './MilestonePreview'
|
|
4
|
+
import { TaskPreview } from './TaskPreview'
|
|
5
|
+
|
|
6
|
+
export function SidePanel() {
|
|
7
|
+
const { content, isOpen, close } = useSidePanel()
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
{/* Backdrop */}
|
|
12
|
+
<div
|
|
13
|
+
className={`fixed inset-0 bg-black/30 backdrop-blur-sm z-40 transition-opacity duration-300 ${
|
|
14
|
+
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
15
|
+
}`}
|
|
16
|
+
onClick={close}
|
|
17
|
+
/>
|
|
18
|
+
|
|
19
|
+
{/* Panel */}
|
|
20
|
+
<div
|
|
21
|
+
className={`fixed top-0 right-0 h-full w-full max-w-2xl bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800 shadow-2xl z-50 transition-transform duration-300 overflow-auto ${
|
|
22
|
+
isOpen ? 'translate-x-0' : 'translate-x-full'
|
|
23
|
+
}`}
|
|
24
|
+
>
|
|
25
|
+
{/* Header */}
|
|
26
|
+
<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">
|
|
27
|
+
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Preview</h2>
|
|
28
|
+
<button
|
|
29
|
+
onClick={close}
|
|
30
|
+
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
31
|
+
aria-label="Close panel"
|
|
32
|
+
>
|
|
33
|
+
<X className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{/* Content */}
|
|
38
|
+
<div className="p-6">
|
|
39
|
+
{content?.type === 'milestone' && <MilestonePreview milestoneId={content.id} />}
|
|
40
|
+
{content?.type === 'task' && <TaskPreview taskId={content.id} />}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -25,9 +25,9 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
|
|
|
25
25
|
const location = useRouterState({ select: (s) => s.location })
|
|
26
26
|
|
|
27
27
|
return (
|
|
28
|
-
<nav className="w-56 border-r border-gray-800 bg-gray-950 flex flex-col shrink-0">
|
|
29
|
-
<div className="p-4 border-b border-gray-800">
|
|
30
|
-
<span className="text-sm font-semibold text-gray-300 tracking-wide">
|
|
28
|
+
<nav className="w-56 border-r border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950 flex flex-col shrink-0">
|
|
29
|
+
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
|
30
|
+
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300 tracking-wide">
|
|
31
31
|
ACP Visualizer
|
|
32
32
|
</span>
|
|
33
33
|
</div>
|
|
@@ -53,8 +53,8 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
|
|
|
53
53
|
to={item.to}
|
|
54
54
|
className={`flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
|
|
55
55
|
isActive
|
|
56
|
-
? 'text-gray-100 bg-gray-800/50'
|
|
57
|
-
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/30'
|
|
56
|
+
? 'text-gray-900 dark:text-gray-100 bg-gray-200 dark:bg-gray-800/50'
|
|
57
|
+
: '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'
|
|
58
58
|
}`}
|
|
59
59
|
>
|
|
60
60
|
<item.icon className="w-4 h-4" />
|
|
@@ -63,10 +63,10 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
|
|
|
63
63
|
)
|
|
64
64
|
})}
|
|
65
65
|
</div>
|
|
66
|
-
<div className="p-3 border-t border-gray-800 space-y-2">
|
|
66
|
+
<div className="p-3 border-t border-gray-200 dark:border-gray-800 space-y-2">
|
|
67
67
|
<Link
|
|
68
68
|
to="/search"
|
|
69
|
-
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-500 bg-gray-900 border border-gray-800 rounded-md hover:text-gray-300 hover:border-gray-600 transition-colors"
|
|
69
|
+
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"
|
|
70
70
|
>
|
|
71
71
|
<Search className="w-4 h-4" />
|
|
72
72
|
Search...
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Link } from '@tanstack/react-router'
|
|
2
2
|
import { StatusDot } from './StatusDot'
|
|
3
3
|
import { PriorityBadge } from './PriorityBadge'
|
|
4
|
+
import { PreviewButton } from './PreviewButton'
|
|
4
5
|
import { ExtraFieldsBadge } from './ExtraFieldsBadge'
|
|
5
6
|
import type { Task } from '../lib/types'
|
|
6
7
|
|
|
@@ -8,7 +9,7 @@ export function TaskList({ tasks }: { tasks: Task[] }) {
|
|
|
8
9
|
if (tasks.length === 0) {
|
|
9
10
|
return (
|
|
10
11
|
<div className="pl-6 py-2">
|
|
11
|
-
<span className="text-xs text-gray-600">No tasks</span>
|
|
12
|
+
<span className="text-xs text-gray-600 dark:text-gray-600">No tasks</span>
|
|
12
13
|
</div>
|
|
13
14
|
)
|
|
14
15
|
}
|
|
@@ -16,20 +17,21 @@ export function TaskList({ tasks }: { tasks: Task[] }) {
|
|
|
16
17
|
return (
|
|
17
18
|
<div className="pl-6 py-1 space-y-0.5">
|
|
18
19
|
{tasks.map((task) => (
|
|
19
|
-
<div key={task.id} className="flex items-center gap-2 py-1 text-sm">
|
|
20
|
+
<div key={task.id} className="flex items-center gap-2 py-1 text-sm group">
|
|
20
21
|
<StatusDot status={task.status} />
|
|
21
22
|
<Link
|
|
22
23
|
to="/tasks/$taskId"
|
|
23
24
|
params={{ taskId: task.id }}
|
|
24
|
-
className={`hover:text-blue-400 transition-colors ${
|
|
25
|
-
task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'
|
|
25
|
+
className={`hover:text-blue-500 dark:hover:text-blue-400 transition-colors ${
|
|
26
|
+
task.status === 'completed' ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-200'
|
|
26
27
|
}`}
|
|
27
28
|
>
|
|
28
29
|
{task.name}
|
|
29
30
|
</Link>
|
|
31
|
+
<PreviewButton type="task" id={task.id} />
|
|
30
32
|
<PriorityBadge priority={task.priority} />
|
|
31
33
|
{task.notes && (
|
|
32
|
-
<span className="text-xs text-gray-600 ml-auto truncate max-w-[200px]">
|
|
34
|
+
<span className="text-xs text-gray-600 dark:text-gray-600 ml-auto truncate max-w-[200px]">
|
|
33
35
|
{task.notes}
|
|
34
36
|
</span>
|
|
35
37
|
)}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Link } from '@tanstack/react-router'
|
|
2
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
3
|
+
import { ExternalLink } from 'lucide-react'
|
|
4
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
5
|
+
import { DetailHeader } from './DetailHeader'
|
|
6
|
+
import { PriorityBadge } from './PriorityBadge'
|
|
7
|
+
import { MarkdownContent, buildLinkMap } from './MarkdownContent'
|
|
8
|
+
import { getMarkdownContent, resolveTaskFile } from '../services/markdown.service'
|
|
9
|
+
import type { MarkdownResult } from '../services/markdown.service'
|
|
10
|
+
|
|
11
|
+
interface TaskPreviewProps {
|
|
12
|
+
taskId: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getGitHubParams(): { owner: string; repo: string } | undefined {
|
|
16
|
+
if (typeof window === 'undefined') return undefined
|
|
17
|
+
const params = new URLSearchParams(window.location.search)
|
|
18
|
+
const repo = params.get('repo')
|
|
19
|
+
if (!repo) return undefined
|
|
20
|
+
const parts = repo.split('/')
|
|
21
|
+
if (parts.length < 2) return undefined
|
|
22
|
+
return { owner: parts[0], repo: parts[1] }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function TaskPreview({ taskId }: TaskPreviewProps) {
|
|
26
|
+
const data = useProgressData()
|
|
27
|
+
const [markdown, setMarkdown] = useState<string | null>(null)
|
|
28
|
+
const [markdownError, setMarkdownError] = useState<string | null>(null)
|
|
29
|
+
const [loading, setLoading] = useState(true)
|
|
30
|
+
|
|
31
|
+
const { task, milestone } = useMemo(() => {
|
|
32
|
+
if (!data) return { task: null, milestone: null }
|
|
33
|
+
|
|
34
|
+
for (const ms of data.milestones) {
|
|
35
|
+
const msTaskList = data.tasks[ms.id] || []
|
|
36
|
+
const foundTask = msTaskList.find((t) => t.id === taskId)
|
|
37
|
+
if (foundTask) {
|
|
38
|
+
return { task: foundTask, milestone: ms }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { task: null, milestone: null }
|
|
42
|
+
}, [data, taskId])
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!task) return
|
|
46
|
+
|
|
47
|
+
setLoading(true)
|
|
48
|
+
setMarkdown(null)
|
|
49
|
+
setMarkdownError(null)
|
|
50
|
+
|
|
51
|
+
const filePath = resolveTaskFile(task)
|
|
52
|
+
if (!filePath) {
|
|
53
|
+
setMarkdownError('No file path for this task')
|
|
54
|
+
setLoading(false)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const github = getGitHubParams()
|
|
59
|
+
|
|
60
|
+
getMarkdownContent({ data: { filePath, github } })
|
|
61
|
+
.then((result: MarkdownResult) => {
|
|
62
|
+
if (result.ok) {
|
|
63
|
+
setMarkdown(result.content)
|
|
64
|
+
} else {
|
|
65
|
+
setMarkdownError(result.error)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
.catch((err: Error) => {
|
|
69
|
+
setMarkdownError(err.message)
|
|
70
|
+
})
|
|
71
|
+
.finally(() => {
|
|
72
|
+
setLoading(false)
|
|
73
|
+
})
|
|
74
|
+
}, [task])
|
|
75
|
+
|
|
76
|
+
const linkMap = useMemo(() => (data ? buildLinkMap(data) : {}), [data])
|
|
77
|
+
const taskFilePath = useMemo(() => resolveTaskFile(task), [task])
|
|
78
|
+
|
|
79
|
+
if (!data || !task || !milestone) {
|
|
80
|
+
return (
|
|
81
|
+
<div className="text-center py-8">
|
|
82
|
+
<p className="text-gray-500 dark:text-gray-400 text-sm">Task not found: {taskId}</p>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const hoursDisplay = task.actual_hours != null
|
|
88
|
+
? `Est: ${task.estimated_hours}h | Actual: ${task.actual_hours}h`
|
|
89
|
+
: `${task.estimated_hours}h`
|
|
90
|
+
|
|
91
|
+
const fields = [
|
|
92
|
+
{ label: 'Est', value: hoursDisplay },
|
|
93
|
+
...(task.started ? [{ label: 'Started', value: task.started }] : []),
|
|
94
|
+
...(task.completed_date ? [{ label: 'Completed', value: task.completed_date }] : []),
|
|
95
|
+
{
|
|
96
|
+
label: 'Milestone',
|
|
97
|
+
value: (
|
|
98
|
+
<Link
|
|
99
|
+
to="/milestones/$milestoneId"
|
|
100
|
+
params={{ milestoneId: milestone.id }}
|
|
101
|
+
className="text-blue-500 dark:text-blue-400 hover:underline"
|
|
102
|
+
>
|
|
103
|
+
{milestone.id.replace('milestone_', 'M')} — {milestone.name}
|
|
104
|
+
</Link>
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div>
|
|
111
|
+
<div className="flex items-start justify-between mb-4">
|
|
112
|
+
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{task.name}</h1>
|
|
113
|
+
<Link
|
|
114
|
+
to="/tasks/$taskId"
|
|
115
|
+
params={{ taskId }}
|
|
116
|
+
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
117
|
+
title="Open full view"
|
|
118
|
+
>
|
|
119
|
+
<ExternalLink className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
120
|
+
</Link>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="flex items-center gap-2 mb-4">
|
|
124
|
+
<PriorityBadge priority={task.priority} />
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<DetailHeader status={task.status} fields={fields} />
|
|
128
|
+
|
|
129
|
+
{task.notes && (
|
|
130
|
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">{task.notes}</p>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{/* Markdown content */}
|
|
134
|
+
{loading ? (
|
|
135
|
+
<p className="text-sm text-gray-600 dark:text-gray-500">Loading document...</p>
|
|
136
|
+
) : markdown ? (
|
|
137
|
+
<div className="prose-sm">
|
|
138
|
+
<MarkdownContent content={markdown} basePath={taskFilePath ?? undefined} linkMap={linkMap} />
|
|
139
|
+
</div>
|
|
140
|
+
) : markdownError ? (
|
|
141
|
+
<div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-xl p-4 text-sm text-gray-600 dark:text-gray-500">
|
|
142
|
+
No document found — {markdownError}
|
|
143
|
+
</div>
|
|
144
|
+
) : null}
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createContext, useContext, useState, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
type PanelContent =
|
|
4
|
+
| { type: 'milestone'; id: string }
|
|
5
|
+
| { type: 'task'; id: string }
|
|
6
|
+
| null
|
|
7
|
+
|
|
8
|
+
interface SidePanelContextValue {
|
|
9
|
+
content: PanelContent
|
|
10
|
+
isOpen: boolean
|
|
11
|
+
openMilestone: (id: string) => void
|
|
12
|
+
openTask: (id: string) => void
|
|
13
|
+
close: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SidePanelContext = createContext<SidePanelContextValue | undefined>(undefined)
|
|
17
|
+
|
|
18
|
+
export function SidePanelProvider({ children }: { children: ReactNode }) {
|
|
19
|
+
const [content, setContent] = useState<PanelContent>(null)
|
|
20
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
21
|
+
|
|
22
|
+
const openMilestone = (id: string) => {
|
|
23
|
+
setContent({ type: 'milestone', id })
|
|
24
|
+
setIsOpen(true)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const openTask = (id: string) => {
|
|
28
|
+
setContent({ type: 'task', id })
|
|
29
|
+
setIsOpen(true)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const close = () => {
|
|
33
|
+
setIsOpen(false)
|
|
34
|
+
setTimeout(() => setContent(null), 300) // Wait for animation
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<SidePanelContext.Provider value={{ content, isOpen, openMilestone, openTask, close }}>
|
|
39
|
+
{children}
|
|
40
|
+
</SidePanelContext.Provider>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useSidePanel() {
|
|
45
|
+
const context = useContext(SidePanelContext)
|
|
46
|
+
if (!context) {
|
|
47
|
+
throw new Error('useSidePanel must be used within SidePanelProvider')
|
|
48
|
+
}
|
|
49
|
+
return context
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
type Theme = 'dark' | 'light'
|
|
4
|
+
|
|
5
|
+
export function useTheme() {
|
|
6
|
+
const [theme, setTheme] = useState<Theme>(() => {
|
|
7
|
+
if (typeof window === 'undefined') return 'dark'
|
|
8
|
+
const stored = localStorage.getItem('theme')
|
|
9
|
+
return (stored === 'light' || stored === 'dark') ? stored : 'dark'
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const root = document.documentElement
|
|
14
|
+
if (theme === 'dark') {
|
|
15
|
+
root.classList.add('dark')
|
|
16
|
+
} else {
|
|
17
|
+
root.classList.remove('dark')
|
|
18
|
+
}
|
|
19
|
+
localStorage.setItem('theme', theme)
|
|
20
|
+
}, [theme])
|
|
21
|
+
|
|
22
|
+
const toggleTheme = () => {
|
|
23
|
+
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { theme, toggleTheme }
|
|
27
|
+
}
|
package/src/routes/__root.tsx
CHANGED
|
@@ -3,12 +3,14 @@ import { useState, useCallback, useEffect } from 'react'
|
|
|
3
3
|
import { useAutoRefresh } from '../lib/useAutoRefresh'
|
|
4
4
|
import { Sidebar } from '../components/Sidebar'
|
|
5
5
|
import { Header } from '../components/Header'
|
|
6
|
+
import { SidePanel } from '../components/SidePanel'
|
|
6
7
|
import { getProgressData } from '../services/progress-database.service'
|
|
7
8
|
import { listProjects, getProjectProgressPath } from '../services/projects.service'
|
|
8
9
|
import { fetchGitHubProgress } from '../services/github.service'
|
|
9
10
|
import type { ProgressData } from '../lib/types'
|
|
10
11
|
import type { AcpProject } from '../services/projects.service'
|
|
11
12
|
import { ProgressProvider } from '../contexts/ProgressContext'
|
|
13
|
+
import { SidePanelProvider } from '../contexts/SidePanelContext'
|
|
12
14
|
|
|
13
15
|
import appCss from '../styles.css?url'
|
|
14
16
|
|
|
@@ -63,8 +65,8 @@ function NotFound() {
|
|
|
63
65
|
return (
|
|
64
66
|
<div className="flex items-center justify-center h-full">
|
|
65
67
|
<div className="text-center">
|
|
66
|
-
<h2 className="text-xl font-semibold text-gray-200 mb-2">Page Not Found</h2>
|
|
67
|
-
<p className="text-sm text-gray-400">
|
|
68
|
+
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-2">Page Not Found</h2>
|
|
69
|
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
68
70
|
The page you're looking for doesn't exist.
|
|
69
71
|
</p>
|
|
70
72
|
</div>
|
|
@@ -154,7 +156,7 @@ function RootLayout() {
|
|
|
154
156
|
return (
|
|
155
157
|
<>
|
|
156
158
|
<AutoRefresh />
|
|
157
|
-
<div className="flex h-screen bg-gray-950 text-gray-100">
|
|
159
|
+
<div className="flex h-screen bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
|
158
160
|
<Sidebar
|
|
159
161
|
projects={context.projects}
|
|
160
162
|
currentProject={currentProject}
|
|
@@ -162,12 +164,15 @@ function RootLayout() {
|
|
|
162
164
|
onGitHubLoad={handleGitHubLoad}
|
|
163
165
|
/>
|
|
164
166
|
<ProgressProvider data={progressData}>
|
|
165
|
-
<
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
<SidePanelProvider>
|
|
168
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
169
|
+
<Header data={progressData} />
|
|
170
|
+
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900">
|
|
171
|
+
<Outlet />
|
|
172
|
+
</main>
|
|
173
|
+
</div>
|
|
174
|
+
<SidePanel />
|
|
175
|
+
</SidePanelProvider>
|
|
171
176
|
</ProgressProvider>
|
|
172
177
|
</div>
|
|
173
178
|
</>
|
package/src/styles.css
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
@import "highlight.js/styles/github-dark.css";
|
|
4
4
|
|
|
5
5
|
html {
|
|
6
|
-
@apply bg-gray-950;
|
|
6
|
+
@apply bg-white dark:bg-gray-950;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
body {
|
|
10
|
-
@apply m-0 bg-gray-950 min-h-screen text-gray-100;
|
|
10
|
+
@apply m-0 bg-white dark:bg-gray-950 min-h-screen text-gray-900 dark:text-gray-100;
|
|
11
11
|
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
|
12
12
|
-webkit-font-smoothing: antialiased;
|
|
13
13
|
-moz-osx-font-smoothing: grayscale;
|