@mdxui/issues 6.0.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.
@@ -0,0 +1,5 @@
1
+
2
+ 
3
+ > @mdxui/issues@5.0.2 typecheck /Users/chrisrisner/Workspace/dot-do/ui/packages/issues
4
+ > tsc --noEmit
5
+
package/CHANGELOG.md ADDED
@@ -0,0 +1,80 @@
1
+ # @mdxui/issues
2
+
3
+ ## 6.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - @mdxui/primitives@6.0.0
8
+ - mdxui@6.0.0
9
+
10
+ ## 5.0.2
11
+
12
+ ### Patch Changes
13
+
14
+ - @mdxui/primitives@5.0.2
15
+ - mdxui@5.0.2
16
+
17
+ ## 5.0.1
18
+
19
+ ### Patch Changes
20
+
21
+ - @mdxui/primitives@5.0.1
22
+ - mdxui@5.0.1
23
+
24
+ ## 5.0.0
25
+
26
+ ### Patch Changes
27
+
28
+ - @mdxui/primitives@5.0.0
29
+ - mdxui@5.0.0
30
+
31
+ ## 4.0.0
32
+
33
+ ### Patch Changes
34
+
35
+ - @mdxui/primitives@4.0.0
36
+ - mdxui@4.0.0
37
+
38
+ ## 3.0.1
39
+
40
+ ### Patch Changes
41
+
42
+ - @mdxui/primitives@3.0.1
43
+ - mdxui@3.0.1
44
+
45
+ ## 3.0.0
46
+
47
+ ### Patch Changes
48
+
49
+ - @mdxui/primitives@3.0.0
50
+ - mdxui@3.0.0
51
+
52
+ ## 2.1.1
53
+
54
+ ### Patch Changes
55
+
56
+ - Updated dependencies
57
+ - mdxui@2.1.1
58
+ - @mdxui/primitives@2.1.1
59
+
60
+ ## 2.0.0
61
+
62
+ ### Patch Changes
63
+
64
+ - Updated dependencies [8101194]
65
+ - mdxui@2.0.0
66
+
67
+ ## 1.0.0
68
+
69
+ ### Patch Changes
70
+
71
+ - Updated dependencies [defc863]
72
+ - mdxui@1.0.0
73
+
74
+ ## 0.4.1
75
+
76
+ ### Patch Changes
77
+
78
+ - Updated dependencies [4d0d1a0]
79
+ - mdxui@0.4.1
80
+ - @mdxui/primitives@0.4.1
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@mdxui/issues",
3
+ "version": "6.0.0",
4
+ "sideEffects": false,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./components": "./src/components/index.ts",
10
+ "./hooks": "./src/hooks/index.ts",
11
+ "./types": "./src/types/index.ts"
12
+ },
13
+ "devDependencies": {
14
+ "@types/react": "^19.2.7",
15
+ "@types/react-dom": "^19.2.3",
16
+ "typescript": "5.9.3",
17
+ "@mdxui/typescript-config": "6.0.0"
18
+ },
19
+ "dependencies": {
20
+ "date-fns": "^4.1.0",
21
+ "lucide-react": "^0.561.0",
22
+ "react": "^19.2.3",
23
+ "zod": "^4.3.5",
24
+ "zustand": "^5.0.9",
25
+ "mdxui": "6.0.0",
26
+ "@mdxui/primitives": "6.0.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit",
33
+ "clean": "rm -rf .turbo node_modules"
34
+ }
35
+ }
@@ -0,0 +1,210 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Card, CardContent, CardHeader, CardTitle } from '@mdxui/primitives/card'
5
+ import { Progress } from '@mdxui/primitives/progress'
6
+ import {
7
+ Collapsible,
8
+ CollapsibleContent,
9
+ CollapsibleTrigger,
10
+ } from '@mdxui/primitives/collapsible'
11
+ import { cn } from '@mdxui/primitives/lib/utils'
12
+ import { ChevronRight, ChevronDown } from 'lucide-react'
13
+ import { TypeBadge, PriorityBadge, StatusBadge } from './issue-badges'
14
+ import { useIssuesStore } from '../hooks/use-issues-store'
15
+ import type { Issue, Epic } from '../types'
16
+
17
+ export interface EpicListProps {
18
+ /** Epics to display */
19
+ epics?: Epic[]
20
+ /** All issues (for calculating epic progress) */
21
+ issues?: Issue[]
22
+ /** Callback when an issue is selected */
23
+ onSelectIssue?: (issue: Issue) => void
24
+ /** Custom class name */
25
+ className?: string
26
+ }
27
+
28
+ export function EpicList({
29
+ epics: propEpics,
30
+ issues: propIssues,
31
+ onSelectIssue,
32
+ className,
33
+ }: EpicListProps) {
34
+ const storeIssues = useIssuesStore((s) => s.issues)
35
+ const selectIssue = useIssuesStore((s) => s.selectIssue)
36
+ const selectedId = useIssuesStore((s) => s.selectedId)
37
+
38
+ const issues = propIssues ?? storeIssues
39
+
40
+ // Derive epics from issues with type='epic' if not provided
41
+ const epics = React.useMemo(() => {
42
+ if (propEpics) return propEpics
43
+
44
+ // Find all epic-type issues
45
+ const epicIssues = issues.filter((i) => i.type === 'epic')
46
+
47
+ return epicIssues.map((epic) => {
48
+ // Find issues that belong to this epic
49
+ const epicChildIssues = issues.filter((i) => i.epic === epic.id)
50
+ const closedCount = epicChildIssues.filter((i) => i.status === 'closed').length
51
+ const progress =
52
+ epicChildIssues.length > 0 ? Math.round((closedCount / epicChildIssues.length) * 100) : 0
53
+
54
+ return {
55
+ id: epic.id,
56
+ title: epic.title,
57
+ description: epic.description,
58
+ issues: epicChildIssues.map((i) => i.id),
59
+ progress,
60
+ createdAt: epic.createdAt,
61
+ updatedAt: epic.updatedAt,
62
+ } satisfies Epic
63
+ })
64
+ }, [propEpics, issues])
65
+
66
+ const handleSelectIssue = (issue: Issue) => {
67
+ selectIssue(issue.id)
68
+ onSelectIssue?.(issue)
69
+ }
70
+
71
+ const getEpicIssues = (epicId: string): Issue[] => {
72
+ return issues.filter((i) => i.epic === epicId)
73
+ }
74
+
75
+ return (
76
+ <div className={cn('space-y-4', className)}>
77
+ {epics.length === 0 ? (
78
+ <Card>
79
+ <CardContent className="py-8 text-center text-muted-foreground">
80
+ No epics found
81
+ </CardContent>
82
+ </Card>
83
+ ) : (
84
+ epics.map((epic) => {
85
+ const epicIssues = getEpicIssues(epic.id)
86
+ const closedCount = epicIssues.filter((i) => i.status === 'closed').length
87
+ const inProgressCount = epicIssues.filter((i) => i.status === 'in_progress').length
88
+ const blockedCount = epicIssues.filter((i) => i.status === 'blocked').length
89
+
90
+ return (
91
+ <EpicCard
92
+ key={epic.id}
93
+ epic={epic}
94
+ issues={epicIssues}
95
+ stats={{
96
+ total: epicIssues.length,
97
+ closed: closedCount,
98
+ inProgress: inProgressCount,
99
+ blocked: blockedCount,
100
+ }}
101
+ selectedId={selectedId}
102
+ onSelectIssue={handleSelectIssue}
103
+ />
104
+ )
105
+ })
106
+ )}
107
+ </div>
108
+ )
109
+ }
110
+
111
+ interface EpicCardProps {
112
+ epic: Epic
113
+ issues: Issue[]
114
+ stats: {
115
+ total: number
116
+ closed: number
117
+ inProgress: number
118
+ blocked: number
119
+ }
120
+ selectedId: string | null
121
+ onSelectIssue: (issue: Issue) => void
122
+ }
123
+
124
+ function EpicCard({ epic, issues, stats, selectedId, onSelectIssue }: EpicCardProps) {
125
+ const [isOpen, setIsOpen] = React.useState(false)
126
+
127
+ return (
128
+ <Card>
129
+ <Collapsible open={isOpen} onOpenChange={setIsOpen}>
130
+ <CollapsibleTrigger asChild>
131
+ <CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
132
+ <div className="flex items-start gap-3">
133
+ <div className="mt-1">
134
+ {isOpen ? (
135
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
136
+ ) : (
137
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
138
+ )}
139
+ </div>
140
+ <div className="flex-1 space-y-2">
141
+ <div className="flex items-center justify-between">
142
+ <CardTitle className="text-base">{epic.title}</CardTitle>
143
+ <span className="text-sm text-muted-foreground font-mono">{epic.id}</span>
144
+ </div>
145
+ {epic.description && (
146
+ <p className="text-sm text-muted-foreground line-clamp-2">
147
+ {epic.description}
148
+ </p>
149
+ )}
150
+ <div className="flex items-center gap-4">
151
+ <div className="flex-1">
152
+ <Progress value={epic.progress} className="h-2" />
153
+ </div>
154
+ <span className="text-sm font-medium">{epic.progress}%</span>
155
+ </div>
156
+ <div className="flex gap-4 text-xs text-muted-foreground">
157
+ <span>{stats.total} issues</span>
158
+ <span className="text-green-600">{stats.closed} closed</span>
159
+ <span className="text-yellow-600">{stats.inProgress} in progress</span>
160
+ {stats.blocked > 0 && (
161
+ <span className="text-red-600">{stats.blocked} blocked</span>
162
+ )}
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </CardHeader>
167
+ </CollapsibleTrigger>
168
+
169
+ <CollapsibleContent>
170
+ <CardContent className="pt-0">
171
+ <div className="border-t pt-4 space-y-2">
172
+ {issues.length === 0 ? (
173
+ <div className="text-sm text-muted-foreground italic">
174
+ No issues in this epic
175
+ </div>
176
+ ) : (
177
+ issues.map((issue) => (
178
+ <div
179
+ key={issue.id}
180
+ role="button"
181
+ tabIndex={0}
182
+ onClick={() => onSelectIssue(issue)}
183
+ onKeyDown={(e) => {
184
+ if (e.key === 'Enter' || e.key === ' ') {
185
+ e.preventDefault()
186
+ onSelectIssue(issue)
187
+ }
188
+ }}
189
+ className={cn(
190
+ 'flex items-center gap-3 p-2 rounded-md cursor-pointer hover:bg-muted/50 transition-colors',
191
+ issue.id === selectedId && 'bg-muted'
192
+ )}
193
+ >
194
+ <span className="font-mono text-xs text-muted-foreground w-24 shrink-0">
195
+ {issue.id}
196
+ </span>
197
+ <TypeBadge type={issue.type} />
198
+ <span className="flex-1 text-sm truncate">{issue.title}</span>
199
+ <StatusBadge status={issue.status} />
200
+ <PriorityBadge priority={issue.priority} />
201
+ </div>
202
+ ))
203
+ )}
204
+ </div>
205
+ </CardContent>
206
+ </CollapsibleContent>
207
+ </Collapsible>
208
+ </Card>
209
+ )
210
+ }
@@ -0,0 +1,9 @@
1
+ export * from './issue-list'
2
+ export * from './issue-board'
3
+ export * from './issue-detail'
4
+ export * from './issue-card'
5
+ export * from './issue-row'
6
+ export * from './issue-dialog'
7
+ export * from './issue-filters'
8
+ export * from './issue-badges'
9
+ export * from './epic-list'
@@ -0,0 +1,135 @@
1
+ 'use client'
2
+
3
+ import { Badge } from '@mdxui/primitives/badge'
4
+ import { cn } from '@mdxui/primitives/lib/utils'
5
+ import type { IssueStatus, IssueType, IssuePriority } from '../types'
6
+
7
+ // =============================================================================
8
+ // Status Badge
9
+ // =============================================================================
10
+
11
+ const statusConfig: Record<IssueStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
12
+ open: { label: 'Open', variant: 'secondary' },
13
+ in_progress: { label: 'In Progress', variant: 'default' },
14
+ blocked: { label: 'Blocked', variant: 'destructive' },
15
+ closed: { label: 'Closed', variant: 'outline' },
16
+ }
17
+
18
+ export interface StatusBadgeProps {
19
+ status: IssueStatus
20
+ className?: string
21
+ }
22
+
23
+ export function StatusBadge({ status, className }: StatusBadgeProps) {
24
+ const config = statusConfig[status]
25
+ return (
26
+ <Badge variant={config.variant} className={className}>
27
+ {config.label}
28
+ </Badge>
29
+ )
30
+ }
31
+
32
+ // =============================================================================
33
+ // Type Badge
34
+ // =============================================================================
35
+
36
+ const typeConfig: Record<IssueType, { label: string; color: string }> = {
37
+ task: { label: 'Task', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
38
+ bug: { label: 'Bug', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
39
+ feature: { label: 'Feature', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
40
+ epic: { label: 'Epic', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' },
41
+ story: { label: 'Story', color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200' },
42
+ chore: { label: 'Chore', color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' },
43
+ }
44
+
45
+ export interface TypeBadgeProps {
46
+ type: IssueType
47
+ className?: string
48
+ }
49
+
50
+ export function TypeBadge({ type, className }: TypeBadgeProps) {
51
+ const config = typeConfig[type]
52
+ return (
53
+ <span
54
+ className={cn(
55
+ 'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium',
56
+ config.color,
57
+ className
58
+ )}
59
+ >
60
+ {config.label}
61
+ </span>
62
+ )
63
+ }
64
+
65
+ // =============================================================================
66
+ // Priority Badge
67
+ // =============================================================================
68
+
69
+ const priorityConfig: Record<IssuePriority, { label: string; color: string }> = {
70
+ 0: { label: 'P0', color: 'bg-red-500 text-white' },
71
+ 1: { label: 'P1', color: 'bg-orange-500 text-white' },
72
+ 2: { label: 'P2', color: 'bg-yellow-500 text-black' },
73
+ 3: { label: 'P3', color: 'bg-blue-500 text-white' },
74
+ 4: { label: 'P4', color: 'bg-gray-400 text-white' },
75
+ }
76
+
77
+ export interface PriorityBadgeProps {
78
+ priority: IssuePriority
79
+ className?: string
80
+ }
81
+
82
+ export function PriorityBadge({ priority, className }: PriorityBadgeProps) {
83
+ const config = priorityConfig[priority] ?? priorityConfig[2]
84
+ return (
85
+ <span
86
+ className={cn(
87
+ 'inline-flex items-center rounded px-1.5 py-0.5 text-xs font-bold',
88
+ config.color,
89
+ className
90
+ )}
91
+ >
92
+ {config.label}
93
+ </span>
94
+ )
95
+ }
96
+
97
+ // =============================================================================
98
+ // Label Badge
99
+ // =============================================================================
100
+
101
+ export interface LabelBadgeProps {
102
+ label: string
103
+ onRemove?: () => void
104
+ className?: string
105
+ }
106
+
107
+ export function LabelBadge({ label, onRemove, className }: LabelBadgeProps) {
108
+ return (
109
+ <Badge variant="outline" className={cn('gap-1', className)}>
110
+ {label}
111
+ {onRemove && (
112
+ <button
113
+ type="button"
114
+ onClick={onRemove}
115
+ className="ml-1 rounded-full hover:bg-muted p-0.5"
116
+ aria-label={`Remove ${label}`}
117
+ >
118
+ <svg
119
+ className="h-3 w-3"
120
+ fill="none"
121
+ viewBox="0 0 24 24"
122
+ stroke="currentColor"
123
+ >
124
+ <path
125
+ strokeLinecap="round"
126
+ strokeLinejoin="round"
127
+ strokeWidth={2}
128
+ d="M6 18L18 6M6 6l12 12"
129
+ />
130
+ </svg>
131
+ </button>
132
+ )}
133
+ </Badge>
134
+ )
135
+ }
@@ -0,0 +1,211 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Card, CardContent, CardHeader, CardTitle } from '@mdxui/primitives/card'
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from '@mdxui/primitives/select'
12
+ import { cn } from '@mdxui/primitives/lib/utils'
13
+ import { IssueCard } from './issue-card'
14
+ import { useIssuesStore, useFilteredIssues } from '../hooks/use-issues-store'
15
+ import {
16
+ defaultBoardColumns,
17
+ type Issue,
18
+ type BoardColumn,
19
+ type ClosedTimeRange,
20
+ } from '../types'
21
+
22
+ export interface IssueBoardProps {
23
+ /** Issues to display (overrides store) */
24
+ issues?: Issue[]
25
+ /** Board columns configuration */
26
+ columns?: BoardColumn[]
27
+ /** Currently selected issue ID */
28
+ selectedId?: string
29
+ /** Callback when an issue is selected */
30
+ onSelect?: (issue: Issue) => void
31
+ /** Custom class name */
32
+ className?: string
33
+ }
34
+
35
+ export function IssueBoard({
36
+ issues: propIssues,
37
+ columns = defaultBoardColumns,
38
+ selectedId: propSelectedId,
39
+ onSelect,
40
+ className,
41
+ }: IssueBoardProps) {
42
+ const storeIssues = useFilteredIssues()
43
+ const storeSelectedId = useIssuesStore((s) => s.selectedId)
44
+ const selectIssue = useIssuesStore((s) => s.selectIssue)
45
+ const closedTimeRange = useIssuesStore((s) => s.closedTimeRange)
46
+ const setClosedTimeRange = useIssuesStore((s) => s.setClosedTimeRange)
47
+
48
+ const issues = propIssues ?? storeIssues
49
+ const selectedId = propSelectedId ?? storeSelectedId
50
+
51
+ // Group issues by column
52
+ const getColumnIssues = (column: BoardColumn): Issue[] => {
53
+ let columnIssues = issues.filter((issue) => column.statuses.includes(issue.status))
54
+
55
+ // For closed column, apply time range filter
56
+ if (column.id === 'closed' && closedTimeRange !== 'all') {
57
+ const now = new Date()
58
+ const cutoff = new Date()
59
+
60
+ if (closedTimeRange === 'today') {
61
+ cutoff.setDate(now.getDate() - 1)
62
+ } else if (closedTimeRange === 'last3days') {
63
+ cutoff.setDate(now.getDate() - 3)
64
+ } else if (closedTimeRange === 'last7days') {
65
+ cutoff.setDate(now.getDate() - 7)
66
+ }
67
+
68
+ columnIssues = columnIssues.filter((i) => {
69
+ if (i.closedAt) {
70
+ return new Date(i.closedAt) >= cutoff
71
+ }
72
+ return false
73
+ })
74
+ }
75
+
76
+ // Sort: active columns by priority asc, closed by closedAt desc
77
+ if (column.id === 'closed') {
78
+ columnIssues.sort((a, b) => {
79
+ const aDate = a.closedAt ? new Date(a.closedAt).getTime() : 0
80
+ const bDate = b.closedAt ? new Date(b.closedAt).getTime() : 0
81
+ return bDate - aDate
82
+ })
83
+ } else {
84
+ columnIssues.sort((a, b) => {
85
+ if (a.priority !== b.priority) return a.priority - b.priority
86
+ return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
87
+ })
88
+ }
89
+
90
+ return columnIssues
91
+ }
92
+
93
+ const handleSelect = (issue: Issue) => {
94
+ selectIssue(issue.id)
95
+ onSelect?.(issue)
96
+ }
97
+
98
+ // Keyboard navigation
99
+ const handleKeyDown = (
100
+ e: React.KeyboardEvent,
101
+ columnIndex: number,
102
+ issueIndex: number,
103
+ columnIssues: Issue[]
104
+ ) => {
105
+ const allColumns = columns.map((col) => getColumnIssues(col))
106
+
107
+ if (e.key === 'ArrowDown' && issueIndex < columnIssues.length - 1) {
108
+ e.preventDefault()
109
+ handleSelect(columnIssues[issueIndex + 1])
110
+ } else if (e.key === 'ArrowUp' && issueIndex > 0) {
111
+ e.preventDefault()
112
+ handleSelect(columnIssues[issueIndex - 1])
113
+ } else if (e.key === 'ArrowRight' && columnIndex < columns.length - 1) {
114
+ e.preventDefault()
115
+ const nextColIssues = allColumns[columnIndex + 1]
116
+ if (nextColIssues.length > 0) {
117
+ handleSelect(nextColIssues[Math.min(issueIndex, nextColIssues.length - 1)])
118
+ }
119
+ } else if (e.key === 'ArrowLeft' && columnIndex > 0) {
120
+ e.preventDefault()
121
+ const prevColIssues = allColumns[columnIndex - 1]
122
+ if (prevColIssues.length > 0) {
123
+ handleSelect(prevColIssues[Math.min(issueIndex, prevColIssues.length - 1)])
124
+ }
125
+ }
126
+ }
127
+
128
+ const getColumnColorClass = (color?: string) => {
129
+ switch (color) {
130
+ case 'red':
131
+ return 'border-t-red-500'
132
+ case 'blue':
133
+ return 'border-t-blue-500'
134
+ case 'yellow':
135
+ return 'border-t-yellow-500'
136
+ case 'green':
137
+ return 'border-t-green-500'
138
+ default:
139
+ return 'border-t-muted-foreground'
140
+ }
141
+ }
142
+
143
+ return (
144
+ <div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
145
+ {columns.map((column, columnIndex) => {
146
+ const columnIssues = getColumnIssues(column)
147
+
148
+ return (
149
+ <Card
150
+ key={column.id}
151
+ className={cn(
152
+ 'flex-shrink-0 w-72 flex flex-col max-h-[calc(100vh-12rem)]',
153
+ 'border-t-4',
154
+ getColumnColorClass(column.color)
155
+ )}
156
+ >
157
+ <CardHeader className="pb-2">
158
+ <div className="flex items-center justify-between">
159
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
160
+ {column.title}
161
+ <span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
162
+ {columnIssues.length}
163
+ </span>
164
+ </CardTitle>
165
+
166
+ {/* Closed column time filter */}
167
+ {column.id === 'closed' && (
168
+ <Select
169
+ value={closedTimeRange}
170
+ onValueChange={(value) => setClosedTimeRange(value as ClosedTimeRange)}
171
+ >
172
+ <SelectTrigger className="h-7 w-28 text-xs">
173
+ <SelectValue />
174
+ </SelectTrigger>
175
+ <SelectContent>
176
+ <SelectItem value="today">Today</SelectItem>
177
+ <SelectItem value="last3days">Last 3 days</SelectItem>
178
+ <SelectItem value="last7days">Last 7 days</SelectItem>
179
+ <SelectItem value="all">All time</SelectItem>
180
+ </SelectContent>
181
+ </Select>
182
+ )}
183
+ </div>
184
+ </CardHeader>
185
+ <CardContent className="flex-1 overflow-auto p-2">
186
+ <div className="space-y-2 pr-2">
187
+ {columnIssues.length === 0 ? (
188
+ <div className="text-center text-sm text-muted-foreground py-8">
189
+ No issues
190
+ </div>
191
+ ) : (
192
+ columnIssues.map((issue, issueIndex) => (
193
+ <IssueCard
194
+ key={issue.id}
195
+ issue={issue}
196
+ isSelected={issue.id === selectedId}
197
+ onClick={() => handleSelect(issue)}
198
+ onKeyDown={(e) =>
199
+ handleKeyDown(e, columnIndex, issueIndex, columnIssues)
200
+ }
201
+ />
202
+ ))
203
+ )}
204
+ </div>
205
+ </CardContent>
206
+ </Card>
207
+ )
208
+ })}
209
+ </div>
210
+ )
211
+ }