@prmichaelsen/acp-visualizer 0.8.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prmichaelsen/acp-visualizer",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Browser-based dashboard for visualizing ACP progress.yaml data",
6
6
  "bin": {
@@ -0,0 +1,100 @@
1
+ import { useState, useEffect, useMemo } from 'react'
2
+ import { Breadcrumb } from './Breadcrumb'
3
+ import { MarkdownContent, buildLinkMap } from './MarkdownContent'
4
+ import { getMarkdownContent, listAgentDirectory } from '../services/markdown.service'
5
+ import { useProgressData } from '../contexts/ProgressContext'
6
+ import type { MarkdownResult, AgentFile } from '../services/markdown.service'
7
+
8
+ interface DocumentDetailProps {
9
+ slug: string
10
+ dirPath: string
11
+ sectionLabel: string
12
+ sectionHref: string
13
+ }
14
+
15
+ /** Read ?repo=owner/repo from URL */
16
+ function getGitHubParams(): { owner: string; repo: string } | undefined {
17
+ if (typeof window === 'undefined') return undefined
18
+ const params = new URLSearchParams(window.location.search)
19
+ const repo = params.get('repo')
20
+ if (!repo) return undefined
21
+ const parts = repo.split('/')
22
+ if (parts.length < 2) return undefined
23
+ return { owner: parts[0], repo: parts[1] }
24
+ }
25
+
26
+ export function DocumentDetail({ slug, dirPath, sectionLabel, sectionHref }: DocumentDetailProps) {
27
+ const data = useProgressData()
28
+ const [markdown, setMarkdown] = useState<string | null>(null)
29
+ const [error, setError] = useState<string | null>(null)
30
+ const [loading, setLoading] = useState(true)
31
+ const [filePath, setFilePath] = useState<string | null>(null)
32
+ const linkMap = useMemo(() => (data ? buildLinkMap(data) : {}), [data])
33
+
34
+ useEffect(() => {
35
+ setLoading(true)
36
+ setMarkdown(null)
37
+ setError(null)
38
+
39
+ const github = getGitHubParams()
40
+ const relativePath = `${dirPath}/${slug}.md`
41
+
42
+ // Try the direct path first, fallback to directory listing if needed
43
+ getMarkdownContent({ data: { filePath: relativePath, github } })
44
+ .then((result: MarkdownResult) => {
45
+ if (result.ok) {
46
+ setMarkdown(result.content)
47
+ setFilePath(result.filePath)
48
+ } else {
49
+ // Fallback: search directory for a file containing the slug
50
+ return listAgentDirectory({ data: { dirPath, github } }).then((listResult) => {
51
+ if (listResult.ok) {
52
+ const match = listResult.files.find((f: AgentFile) => f.name === slug)
53
+ if (match) {
54
+ return getMarkdownContent({ data: { filePath: match.relativePath, github } })
55
+ .then((mdResult: MarkdownResult) => {
56
+ if (mdResult.ok) {
57
+ setMarkdown(mdResult.content)
58
+ setFilePath(mdResult.filePath)
59
+ } else {
60
+ setError(mdResult.error)
61
+ }
62
+ })
63
+ }
64
+ }
65
+ setError(result.error)
66
+ })
67
+ }
68
+ })
69
+ .catch((err: Error) => setError(err.message))
70
+ .finally(() => setLoading(false))
71
+ }, [slug, dirPath])
72
+
73
+ const displayName = slug
74
+ .replace(/^[a-z0-9-]+\./, '')
75
+ .replace(/[-_]/g, ' ')
76
+ .replace(/\b\w/g, (c) => c.toUpperCase())
77
+
78
+ return (
79
+ <div className="p-6 max-w-4xl">
80
+ <Breadcrumb
81
+ items={[
82
+ { label: sectionLabel, href: sectionHref },
83
+ { label: displayName },
84
+ ]}
85
+ />
86
+
87
+ <h1 className="text-xl font-semibold text-gray-100 mb-6">{displayName}</h1>
88
+
89
+ {loading ? (
90
+ <p className="text-sm text-gray-600">Loading document...</p>
91
+ ) : markdown ? (
92
+ <MarkdownContent content={markdown} basePath={filePath ?? undefined} linkMap={linkMap} />
93
+ ) : error ? (
94
+ <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4 text-sm text-gray-500">
95
+ No document found — {error}
96
+ </div>
97
+ ) : null}
98
+ </div>
99
+ )
100
+ }
@@ -0,0 +1,90 @@
1
+ import { Link } from '@tanstack/react-router'
2
+ import { useState, useEffect } from 'react'
3
+ import { listAgentDirectory } from '../services/markdown.service'
4
+ import type { AgentFile } from '../services/markdown.service'
5
+ import { FileText } from 'lucide-react'
6
+
7
+ interface DocumentListProps {
8
+ title: string
9
+ dirPath: string
10
+ baseTo: string
11
+ github?: { owner: string; repo: string; branch?: string; token?: string }
12
+ }
13
+
14
+ export function DocumentList({ title, dirPath, baseTo, github }: DocumentListProps) {
15
+ const [files, setFiles] = useState<AgentFile[]>([])
16
+ const [error, setError] = useState<string | null>(null)
17
+ const [loading, setLoading] = useState(true)
18
+
19
+ useEffect(() => {
20
+ setLoading(true)
21
+ listAgentDirectory({ data: { dirPath, github } })
22
+ .then((result) => {
23
+ if (result.ok) {
24
+ setFiles(result.files)
25
+ } else {
26
+ setError(result.error)
27
+ }
28
+ })
29
+ .catch((err: Error) => setError(err.message))
30
+ .finally(() => setLoading(false))
31
+ }, [dirPath, github])
32
+
33
+ if (loading) {
34
+ return (
35
+ <div className="p-6">
36
+ <h2 className="text-lg font-semibold mb-4">{title}</h2>
37
+ <p className="text-sm text-gray-600">Loading...</p>
38
+ </div>
39
+ )
40
+ }
41
+
42
+ if (error) {
43
+ return (
44
+ <div className="p-6">
45
+ <h2 className="text-lg font-semibold mb-4">{title}</h2>
46
+ <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4 text-sm text-gray-500">
47
+ {error}
48
+ </div>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ if (files.length === 0) {
54
+ return (
55
+ <div className="p-6">
56
+ <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>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <div className="p-6">
64
+ <h2 className="text-lg font-semibold mb-4">{title}</h2>
65
+ <div className="bg-gray-900/50 border border-gray-800 rounded-xl divide-y divide-gray-800">
66
+ {files.map((file) => (
67
+ <Link
68
+ key={file.name}
69
+ to={baseTo + '/$slug'}
70
+ params={{ slug: file.name }}
71
+ className="flex items-center gap-3 px-4 py-3 text-sm hover:bg-gray-800/50 transition-colors first:rounded-t-xl last:rounded-b-xl"
72
+ >
73
+ <FileText className="w-4 h-4 text-gray-500 shrink-0" />
74
+ <span className="text-gray-200">{formatName(file.name)}</span>
75
+ </Link>
76
+ ))}
77
+ </div>
78
+ <p className="text-xs text-gray-600 mt-3">{files.length} document{files.length !== 1 ? 's' : ''}</p>
79
+ </div>
80
+ )
81
+ }
82
+
83
+ /** Turn "local.dashboard-layout-routing" into "Dashboard Layout Routing" */
84
+ function formatName(name: string): string {
85
+ // Strip common prefixes like "local." or "core-sdk."
86
+ const stripped = name.replace(/^[a-z0-9-]+\./, '')
87
+ return stripped
88
+ .replace(/[-_]/g, ' ')
89
+ .replace(/\b\w/g, (c) => c.toUpperCase())
90
+ }
@@ -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-3 w-48">
18
- <ProgressBar value={data.progress.overall} size="sm" />
19
- <span className="text-xs text-gray-400 font-mono">{data.progress.overall}%</span>
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
  )
@@ -1,6 +1,8 @@
1
1
  import { Link } from '@tanstack/react-router'
2
2
  import { StatusBadge } from './StatusBadge'
3
+ import { PriorityBadge } from './PriorityBadge'
3
4
  import { ProgressBar } from './ProgressBar'
5
+ import { PreviewButton } from './PreviewButton'
4
6
  import { TaskList } from './TaskList'
5
7
  import type { Milestone, Task, Status } from '../lib/types'
6
8
  import { useState } from 'react'
@@ -28,15 +30,19 @@ function KanbanCard({
28
30
  const [expanded, setExpanded] = useState(false)
29
31
 
30
32
  return (
31
- <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">
32
34
  <div className="flex items-start justify-between gap-2 mb-2">
33
- <Link
34
- to="/milestones/$milestoneId"
35
- params={{ milestoneId: milestone.id }}
36
- className="text-sm font-medium leading-tight hover:text-blue-400 transition-colors"
37
- >
38
- {milestone.name}
39
- </Link>
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>
45
+ <PriorityBadge priority={milestone.priority} />
40
46
  </div>
41
47
  <div className="flex items-center gap-2 mb-2">
42
48
  <div className="flex-1">
@@ -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
+ }
@@ -10,7 +10,9 @@ import {
10
10
  } from '@tanstack/react-table'
11
11
  import { ChevronDown, ChevronRight, ArrowUpDown } from 'lucide-react'
12
12
  import { StatusBadge } from './StatusBadge'
13
+ import { PriorityBadge } from './PriorityBadge'
13
14
  import { ProgressBar } from './ProgressBar'
15
+ import { PreviewButton } from './PreviewButton'
14
16
  import { TaskList } from './TaskList'
15
17
  import type { Milestone, Task } from '../lib/types'
16
18
 
@@ -53,14 +55,17 @@ export function MilestoneTable({ milestones, tasks }: MilestoneTableProps) {
53
55
  columnHelper.accessor('name', {
54
56
  header: 'Milestone',
55
57
  cell: (info) => (
56
- <Link
57
- to="/milestones/$milestoneId"
58
- params={{ milestoneId: info.row.original.id }}
59
- className="text-sm font-medium text-gray-200 hover:text-blue-400 transition-colors"
60
- onClick={(e) => e.stopPropagation()}
61
- >
62
- {info.getValue()}
63
- </Link>
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>
64
69
  ),
65
70
  }),
66
71
  columnHelper.accessor('status', {
@@ -68,6 +73,11 @@ export function MilestoneTable({ milestones, tasks }: MilestoneTableProps) {
68
73
  cell: (info) => <StatusBadge status={info.getValue()} />,
69
74
  size: 120,
70
75
  }),
76
+ columnHelper.accessor('priority', {
77
+ header: 'Priority',
78
+ cell: (info) => <PriorityBadge priority={info.getValue()} />,
79
+ size: 100,
80
+ }),
71
81
  columnHelper.accessor('progress', {
72
82
  header: 'Progress',
73
83
  cell: (info) => (
@@ -2,7 +2,9 @@ import { useState } from 'react'
2
2
  import { Link } from '@tanstack/react-router'
3
3
  import { ChevronDown, ChevronRight } from 'lucide-react'
4
4
  import { StatusBadge } from './StatusBadge'
5
+ import { PriorityBadge } from './PriorityBadge'
5
6
  import { ProgressBar } from './ProgressBar'
7
+ import { PreviewButton } from './PreviewButton'
6
8
  import { TaskList } from './TaskList'
7
9
  import { useCollapse } from '../lib/useCollapse'
8
10
  import type { Milestone, Task } from '../lib/types'
@@ -26,29 +28,33 @@ function MilestoneTreeRow({
26
28
  const collapse = useCollapse(expanded)
27
29
 
28
30
  return (
29
- <div className="border-b border-gray-800/50">
31
+ <div className="border-b border-gray-200 dark:border-gray-800/50 group">
30
32
  <button
31
33
  onClick={onToggle}
32
- 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"
33
35
  >
34
36
  {expanded ? (
35
- <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" />
36
38
  ) : (
37
- <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" />
38
40
  )}
39
- <Link
40
- to="/milestones/$milestoneId"
41
- params={{ milestoneId: milestone.id }}
42
- className="flex-1 text-sm font-medium hover:text-blue-400 transition-colors"
43
- onClick={(e) => e.stopPropagation()}
44
- >
45
- {milestone.name}
46
- </Link>
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>
47
52
  <StatusBadge status={milestone.status} />
53
+ <PriorityBadge priority={milestone.priority} />
48
54
  <div className="w-20">
49
55
  <ProgressBar value={milestone.progress} size="sm" />
50
56
  </div>
51
- <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">
52
58
  {milestone.tasks_completed}/{milestone.tasks_total}
53
59
  </span>
54
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,20 @@
1
+ import type { Priority } from '../lib/types'
2
+
3
+ const priorityStyles: Record<Priority, string> = {
4
+ critical: 'bg-rose-500/20 text-rose-400',
5
+ high: 'bg-amber-500/20 text-amber-400',
6
+ medium: 'bg-blue-500/20 text-blue-400',
7
+ low: 'bg-zinc-500/20 text-zinc-400',
8
+ }
9
+
10
+ export function PriorityBadge({ priority }: { priority: Priority | undefined }) {
11
+ if (!priority) return null
12
+
13
+ return (
14
+ <span
15
+ className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium uppercase ${priorityStyles[priority] ?? priorityStyles.medium}`}
16
+ >
17
+ {priority}
18
+ </span>
19
+ )
20
+ }
@@ -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
+ }