kanban-lite 1.0.4
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/.editorconfig +9 -0
- package/.github/workflows/ci.yml +59 -0
- package/.github/workflows/release.yml +75 -0
- package/.prettierignore +6 -0
- package/.prettierrc.yaml +4 -0
- package/.vscode/extensions.json +3 -0
- package/.vscode/launch.json +17 -0
- package/.vscode/settings.json +21 -0
- package/.vscode/tasks.json +22 -0
- package/.vscodeignore +11 -0
- package/CHANGELOG.md +184 -0
- package/CLAUDE.md +58 -0
- package/CONTRIBUTING.md +114 -0
- package/LICENSE +22 -0
- package/README.md +482 -0
- package/SKILL.md +237 -0
- package/dist/cli.js +8716 -0
- package/dist/extension.js +8463 -0
- package/dist/mcp-server.js +1327 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/standalone-webview/index.js +85 -0
- package/dist/standalone-webview/index.js.map +1 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/standalone-webview/style.css +1 -0
- package/dist/standalone.js +7513 -0
- package/dist/webview/icons-Dx9MGYqN.js +180 -0
- package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/webview/index.js +85 -0
- package/dist/webview/index.js.map +1 -0
- package/dist/webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/webview/style.css +1 -0
- package/docs/images/board-overview.png +0 -0
- package/docs/images/editor-view.png +0 -0
- package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
- package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
- package/eslint.config.mjs +31 -0
- package/package.json +161 -0
- package/postcss.config.js +6 -0
- package/resources/icon-light.png +0 -0
- package/resources/icon-light.svg +105 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +105 -0
- package/resources/kanban-dark.svg +21 -0
- package/resources/kanban-light.svg +21 -0
- package/resources/kanban.svg +21 -0
- package/src/cli/index.ts +846 -0
- package/src/extension/FeatureHeaderProvider.ts +370 -0
- package/src/extension/KanbanPanel.ts +973 -0
- package/src/extension/SidebarViewProvider.ts +507 -0
- package/src/extension/featureFileUtils.ts +82 -0
- package/src/extension/index.ts +234 -0
- package/src/mcp-server/index.ts +632 -0
- package/src/sdk/KanbanSDK.ts +349 -0
- package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
- package/src/sdk/__tests__/parser.test.ts +170 -0
- package/src/sdk/fileUtils.ts +76 -0
- package/src/sdk/index.ts +6 -0
- package/src/sdk/parser.ts +70 -0
- package/src/sdk/types.ts +15 -0
- package/src/shared/config.ts +113 -0
- package/src/shared/editorTypes.ts +14 -0
- package/src/shared/types.ts +120 -0
- package/src/standalone/__tests__/server.integration.test.ts +1916 -0
- package/src/standalone/__tests__/webhooks.test.ts +357 -0
- package/src/standalone/fileUtils.ts +70 -0
- package/src/standalone/index.ts +71 -0
- package/src/standalone/server.ts +1046 -0
- package/src/standalone/webhooks.ts +135 -0
- package/src/webview/App.tsx +469 -0
- package/src/webview/assets/main.css +329 -0
- package/src/webview/assets/standalone-theme.css +130 -0
- package/src/webview/components/ColumnDialog.tsx +119 -0
- package/src/webview/components/CreateFeatureDialog.tsx +524 -0
- package/src/webview/components/DatePicker.tsx +185 -0
- package/src/webview/components/FeatureCard.tsx +186 -0
- package/src/webview/components/FeatureEditor.tsx +623 -0
- package/src/webview/components/KanbanBoard.tsx +144 -0
- package/src/webview/components/KanbanColumn.tsx +159 -0
- package/src/webview/components/MarkdownEditor.tsx +291 -0
- package/src/webview/components/PrioritySelect.tsx +39 -0
- package/src/webview/components/QuickAddInput.tsx +72 -0
- package/src/webview/components/SettingsPanel.tsx +284 -0
- package/src/webview/components/Toolbar.tsx +175 -0
- package/src/webview/components/UndoToast.tsx +70 -0
- package/src/webview/index.html +12 -0
- package/src/webview/lib/utils.ts +6 -0
- package/src/webview/main.tsx +11 -0
- package/src/webview/standalone-main.tsx +13 -0
- package/src/webview/standalone-shim.ts +132 -0
- package/src/webview/standalone.html +12 -0
- package/src/webview/store/index.ts +241 -0
- package/tailwind.config.js +53 -0
- package/tsconfig.json +22 -0
- package/vite.config.ts +36 -0
- package/vite.standalone.config.ts +62 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useMemo } from 'react'
|
|
2
|
+
import { X, ChevronDown, User, Tag, Check, CircleDot, Signal, Calendar } from 'lucide-react'
|
|
3
|
+
import type { FeatureStatus, Priority } from '../../shared/types'
|
|
4
|
+
import { useStore } from '../store'
|
|
5
|
+
import { cn } from '../lib/utils'
|
|
6
|
+
import { DatePicker } from './DatePicker'
|
|
7
|
+
import { MarkdownEditor } from './MarkdownEditor'
|
|
8
|
+
|
|
9
|
+
interface CreateFeatureDialogProps {
|
|
10
|
+
isOpen: boolean
|
|
11
|
+
onClose: () => void
|
|
12
|
+
onCreate: (data: { status: FeatureStatus; priority: Priority; content: string; assignee: string | null; dueDate: string | null; labels: string[] }) => void
|
|
13
|
+
initialStatus?: FeatureStatus
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const priorityConfig: { value: Priority; label: string; dot: string }[] = [
|
|
17
|
+
{ value: 'critical', label: 'Critical', dot: 'bg-red-500' },
|
|
18
|
+
{ value: 'high', label: 'High', dot: 'bg-orange-500' },
|
|
19
|
+
{ value: 'medium', label: 'Medium', dot: 'bg-yellow-500' },
|
|
20
|
+
{ value: 'low', label: 'Low', dot: 'bg-green-500' }
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const statusConfig: { value: FeatureStatus; label: string; dot: string }[] = [
|
|
24
|
+
{ value: 'backlog', label: 'Backlog', dot: 'bg-zinc-400' },
|
|
25
|
+
{ value: 'todo', label: 'To Do', dot: 'bg-blue-400' },
|
|
26
|
+
{ value: 'in-progress', label: 'In Progress', dot: 'bg-amber-400' },
|
|
27
|
+
{ value: 'review', label: 'Review', dot: 'bg-purple-400' },
|
|
28
|
+
{ value: 'done', label: 'Done', dot: 'bg-emerald-400' }
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
interface DropdownProps {
|
|
32
|
+
value: string
|
|
33
|
+
options: { value: string; label: string; dot?: string }[]
|
|
34
|
+
onChange: (value: string) => void
|
|
35
|
+
className?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Dropdown({ value, options, onChange, className }: DropdownProps) {
|
|
39
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
40
|
+
const current = options.find(o => o.value === value)
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className={cn('relative', className)}>
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
47
|
+
className="flex items-center gap-2 px-2 py-1 text-xs font-medium rounded transition-colors"
|
|
48
|
+
style={{
|
|
49
|
+
color: 'var(--vscode-foreground)',
|
|
50
|
+
}}
|
|
51
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
|
|
52
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
53
|
+
>
|
|
54
|
+
{current?.dot && <span className={cn('w-2 h-2 rounded-full shrink-0', current.dot)} />}
|
|
55
|
+
<span>{current?.label}</span>
|
|
56
|
+
<ChevronDown size={12} style={{ color: 'var(--vscode-descriptionForeground)' }} className="ml-0.5" />
|
|
57
|
+
</button>
|
|
58
|
+
{isOpen && (
|
|
59
|
+
<>
|
|
60
|
+
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
|
|
61
|
+
<div
|
|
62
|
+
className="absolute top-full left-0 mt-1 z-20 rounded-lg shadow-lg py-1 min-w-[140px]"
|
|
63
|
+
style={{
|
|
64
|
+
background: 'var(--vscode-dropdown-background)',
|
|
65
|
+
border: '1px solid var(--vscode-dropdown-border, var(--vscode-panel-border))',
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{options.map(option => (
|
|
69
|
+
<button
|
|
70
|
+
key={option.value}
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => {
|
|
73
|
+
onChange(option.value)
|
|
74
|
+
setIsOpen(false)
|
|
75
|
+
}}
|
|
76
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors"
|
|
77
|
+
style={{
|
|
78
|
+
color: 'var(--vscode-dropdown-foreground)',
|
|
79
|
+
background: option.value === value ? 'var(--vscode-list-activeSelectionBackground)' : undefined,
|
|
80
|
+
}}
|
|
81
|
+
onMouseEnter={e => {
|
|
82
|
+
if (option.value !== value) e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'
|
|
83
|
+
}}
|
|
84
|
+
onMouseLeave={e => {
|
|
85
|
+
if (option.value !== value) e.currentTarget.style.background = 'transparent'
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
{option.dot && <span className={cn('w-2 h-2 rounded-full shrink-0', option.dot)} />}
|
|
89
|
+
<span className="flex-1 text-left">{option.label}</span>
|
|
90
|
+
{option.value === value && <Check size={12} style={{ color: 'var(--vscode-focusBorder)' }} className="shrink-0" />}
|
|
91
|
+
</button>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function AssigneeInput({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
|
101
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
102
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
103
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
104
|
+
const features = useStore(s => s.features)
|
|
105
|
+
|
|
106
|
+
const existingAssignees = useMemo(() => {
|
|
107
|
+
const assignees = new Set<string>()
|
|
108
|
+
features.forEach(f => { if (f.assignee) assignees.add(f.assignee) })
|
|
109
|
+
return Array.from(assignees).sort()
|
|
110
|
+
}, [features])
|
|
111
|
+
|
|
112
|
+
const suggestions = useMemo(() => {
|
|
113
|
+
if (!value.trim()) return existingAssignees
|
|
114
|
+
return existingAssignees.filter(a => a.toLowerCase().includes(value.toLowerCase()) && a !== value)
|
|
115
|
+
}, [value, existingAssignees])
|
|
116
|
+
|
|
117
|
+
const showSuggestions = isFocused && suggestions.length > 0
|
|
118
|
+
|
|
119
|
+
const initials = value.trim()
|
|
120
|
+
? value.trim().split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
|
121
|
+
: null
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div ref={containerRef} className="relative flex-1">
|
|
125
|
+
<div
|
|
126
|
+
className="flex items-center gap-2 cursor-text"
|
|
127
|
+
onClick={() => inputRef.current?.focus()}
|
|
128
|
+
>
|
|
129
|
+
{initials && (
|
|
130
|
+
<span
|
|
131
|
+
className="shrink-0 w-4 h-4 rounded-full flex items-center justify-center text-[8px] font-bold"
|
|
132
|
+
style={{
|
|
133
|
+
background: 'var(--vscode-badge-background)',
|
|
134
|
+
color: 'var(--vscode-badge-foreground)',
|
|
135
|
+
}}
|
|
136
|
+
>{initials}</span>
|
|
137
|
+
)}
|
|
138
|
+
<input
|
|
139
|
+
ref={inputRef}
|
|
140
|
+
type="text"
|
|
141
|
+
value={value}
|
|
142
|
+
onChange={(e) => onChange(e.target.value)}
|
|
143
|
+
onFocus={() => setIsFocused(true)}
|
|
144
|
+
onBlur={() => setTimeout(() => setIsFocused(false), 150)}
|
|
145
|
+
placeholder="No assignee"
|
|
146
|
+
className="flex-1 bg-transparent border-none outline-none text-xs"
|
|
147
|
+
style={{ color: value ? 'var(--vscode-foreground)' : 'var(--vscode-descriptionForeground)' }}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
{showSuggestions && (
|
|
151
|
+
<div
|
|
152
|
+
className="absolute top-full left-0 mt-1 z-20 rounded-lg shadow-lg py-1 max-h-[160px] overflow-auto min-w-[180px]"
|
|
153
|
+
style={{
|
|
154
|
+
background: 'var(--vscode-dropdown-background)',
|
|
155
|
+
border: '1px solid var(--vscode-dropdown-border, var(--vscode-panel-border))',
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{suggestions.map(assignee => (
|
|
159
|
+
<button
|
|
160
|
+
key={assignee}
|
|
161
|
+
type="button"
|
|
162
|
+
onMouseDown={(e) => { e.preventDefault(); onChange(assignee) }}
|
|
163
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors"
|
|
164
|
+
style={{ color: 'var(--vscode-dropdown-foreground)' }}
|
|
165
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
|
|
166
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
167
|
+
>
|
|
168
|
+
<span
|
|
169
|
+
className="shrink-0 w-4 h-4 rounded-full flex items-center justify-center text-[8px] font-bold"
|
|
170
|
+
style={{
|
|
171
|
+
background: 'var(--vscode-badge-background)',
|
|
172
|
+
color: 'var(--vscode-badge-foreground)',
|
|
173
|
+
}}
|
|
174
|
+
>{assignee.split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2)}</span>
|
|
175
|
+
<span>{assignee}</span>
|
|
176
|
+
</button>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function LabelInput({ labels, onChange }: { labels: string[]; onChange: (labels: string[]) => void }) {
|
|
185
|
+
const [newLabel, setNewLabel] = useState('')
|
|
186
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
187
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
188
|
+
const features = useStore(s => s.features)
|
|
189
|
+
|
|
190
|
+
const existingLabels = useMemo(() => {
|
|
191
|
+
const labelSet = new Set<string>()
|
|
192
|
+
features.forEach(f => f.labels.forEach(l => labelSet.add(l)))
|
|
193
|
+
return Array.from(labelSet).sort()
|
|
194
|
+
}, [features])
|
|
195
|
+
|
|
196
|
+
const suggestions = useMemo(() => {
|
|
197
|
+
const available = existingLabels.filter(l => !labels.includes(l))
|
|
198
|
+
if (!newLabel.trim()) return available
|
|
199
|
+
return available.filter(l => l.toLowerCase().includes(newLabel.toLowerCase()))
|
|
200
|
+
}, [newLabel, existingLabels, labels])
|
|
201
|
+
|
|
202
|
+
const showSuggestions = isFocused && suggestions.length > 0
|
|
203
|
+
|
|
204
|
+
const addLabel = (label?: string) => {
|
|
205
|
+
const l = (label || newLabel).trim()
|
|
206
|
+
if (l && !labels.includes(l)) {
|
|
207
|
+
onChange([...labels, l])
|
|
208
|
+
}
|
|
209
|
+
setNewLabel('')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div className="relative flex-1">
|
|
214
|
+
<div
|
|
215
|
+
className="flex items-center gap-1.5 flex-wrap cursor-text"
|
|
216
|
+
onClick={() => inputRef.current?.focus()}
|
|
217
|
+
>
|
|
218
|
+
{labels.map(label => (
|
|
219
|
+
<span
|
|
220
|
+
key={label}
|
|
221
|
+
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded"
|
|
222
|
+
style={{
|
|
223
|
+
background: 'var(--vscode-badge-background)',
|
|
224
|
+
color: 'var(--vscode-badge-foreground)',
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{label}
|
|
228
|
+
<button
|
|
229
|
+
onClick={(e) => { e.stopPropagation(); onChange(labels.filter(l => l !== label)) }}
|
|
230
|
+
className="hover:text-red-500 transition-colors"
|
|
231
|
+
>
|
|
232
|
+
<X size={9} />
|
|
233
|
+
</button>
|
|
234
|
+
</span>
|
|
235
|
+
))}
|
|
236
|
+
<input
|
|
237
|
+
ref={inputRef}
|
|
238
|
+
type="text"
|
|
239
|
+
value={newLabel}
|
|
240
|
+
onChange={(e) => setNewLabel(e.target.value)}
|
|
241
|
+
onFocus={() => setIsFocused(true)}
|
|
242
|
+
onBlur={() => setTimeout(() => setIsFocused(false), 150)}
|
|
243
|
+
onKeyDown={(e) => {
|
|
244
|
+
if (e.key === 'Enter') { e.preventDefault(); addLabel() }
|
|
245
|
+
if (e.key === 'Backspace' && !newLabel && labels.length > 0) {
|
|
246
|
+
onChange(labels.slice(0, -1))
|
|
247
|
+
}
|
|
248
|
+
if (e.key === 'Escape') { setNewLabel(''); inputRef.current?.blur() }
|
|
249
|
+
}}
|
|
250
|
+
placeholder={labels.length === 0 ? 'Add labels...' : ''}
|
|
251
|
+
className="flex-1 min-w-[60px] bg-transparent border-none outline-none text-xs"
|
|
252
|
+
style={{ color: 'var(--vscode-foreground)' }}
|
|
253
|
+
/>
|
|
254
|
+
</div>
|
|
255
|
+
{showSuggestions && (
|
|
256
|
+
<div
|
|
257
|
+
className="absolute top-full left-0 mt-1 z-20 rounded-lg shadow-lg py-1 max-h-[160px] overflow-auto min-w-[180px]"
|
|
258
|
+
style={{
|
|
259
|
+
background: 'var(--vscode-dropdown-background)',
|
|
260
|
+
border: '1px solid var(--vscode-dropdown-border, var(--vscode-panel-border))',
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
{suggestions.map(label => (
|
|
264
|
+
<button
|
|
265
|
+
key={label}
|
|
266
|
+
type="button"
|
|
267
|
+
onMouseDown={(e) => { e.preventDefault(); addLabel(label) }}
|
|
268
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors"
|
|
269
|
+
style={{ color: 'var(--vscode-dropdown-foreground)' }}
|
|
270
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
|
|
271
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
272
|
+
>
|
|
273
|
+
<span
|
|
274
|
+
className="inline-block px-1.5 py-0.5 text-[10px] font-medium rounded"
|
|
275
|
+
style={{
|
|
276
|
+
background: 'var(--vscode-badge-background)',
|
|
277
|
+
color: 'var(--vscode-badge-foreground)',
|
|
278
|
+
}}
|
|
279
|
+
>{label}</span>
|
|
280
|
+
</button>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function PropertyRow({ label, icon, children }: { label: string; icon: React.ReactNode; children: React.ReactNode }) {
|
|
289
|
+
return (
|
|
290
|
+
<div
|
|
291
|
+
className="flex items-center gap-3 px-4 py-[5px] transition-colors"
|
|
292
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
|
|
293
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
294
|
+
>
|
|
295
|
+
<div className="flex items-center gap-2 w-[90px] shrink-0">
|
|
296
|
+
<span style={{ color: 'var(--vscode-descriptionForeground)' }}>{icon}</span>
|
|
297
|
+
<span className="text-[11px]" style={{ color: 'var(--vscode-descriptionForeground)' }}>{label}</span>
|
|
298
|
+
</div>
|
|
299
|
+
<div className="flex-1 min-w-0">
|
|
300
|
+
{children}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Wrapper that unmounts and remounts content when dialog opens to reset state
|
|
307
|
+
export function CreateFeatureDialog({ isOpen, ...props }: CreateFeatureDialogProps) {
|
|
308
|
+
if (!isOpen) return null
|
|
309
|
+
return <CreateFeatureDialogContent isOpen={isOpen} {...props} />
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function CreateFeatureDialogContent({
|
|
313
|
+
isOpen,
|
|
314
|
+
onClose,
|
|
315
|
+
onCreate,
|
|
316
|
+
initialStatus
|
|
317
|
+
}: CreateFeatureDialogProps) {
|
|
318
|
+
const { cardSettings } = useStore()
|
|
319
|
+
const [title, setTitle] = useState('')
|
|
320
|
+
const [status, setStatus] = useState<FeatureStatus>(initialStatus ?? cardSettings.defaultStatus)
|
|
321
|
+
const [priority, setPriority] = useState<Priority>(cardSettings.defaultPriority)
|
|
322
|
+
const [assignee, setAssignee] = useState('')
|
|
323
|
+
const [dueDate, setDueDate] = useState('')
|
|
324
|
+
const [labels, setLabels] = useState<string[]>([])
|
|
325
|
+
const [description, setDescription] = useState('')
|
|
326
|
+
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
327
|
+
|
|
328
|
+
// Focus input on mount
|
|
329
|
+
useEffect(() => {
|
|
330
|
+
const timer = setTimeout(() => inputRef.current?.focus(), 50)
|
|
331
|
+
return () => clearTimeout(timer)
|
|
332
|
+
}, [])
|
|
333
|
+
|
|
334
|
+
const handleSubmit = () => {
|
|
335
|
+
const desc = description.trim()
|
|
336
|
+
const heading = title.trim()
|
|
337
|
+
const content = heading
|
|
338
|
+
? `# ${heading}${desc ? `\n\n${desc}` : ''}`
|
|
339
|
+
: desc
|
|
340
|
+
onCreate({ status, priority, content, assignee: assignee.trim() || null, dueDate: dueDate || null, labels })
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const handleSaveAndClose = () => {
|
|
344
|
+
handleSubmit()
|
|
345
|
+
onClose()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const handleCancel = () => {
|
|
349
|
+
onClose()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
354
|
+
if (e.key === 'Escape') {
|
|
355
|
+
handleCancel()
|
|
356
|
+
}
|
|
357
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
358
|
+
handleSaveAndClose()
|
|
359
|
+
}
|
|
360
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
361
|
+
e.preventDefault()
|
|
362
|
+
handleSaveAndClose()
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (isOpen) {
|
|
367
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
368
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
return (
|
|
373
|
+
<div className="fixed inset-0 z-50 flex justify-end">
|
|
374
|
+
<div className="absolute inset-0 bg-black/30" onClick={handleCancel} />
|
|
375
|
+
<div
|
|
376
|
+
className="relative h-full w-1/2 shadow-xl flex flex-col animate-in slide-in-from-right duration-200"
|
|
377
|
+
style={{
|
|
378
|
+
background: 'var(--vscode-editor-background)',
|
|
379
|
+
borderLeft: '1px solid var(--vscode-panel-border)',
|
|
380
|
+
}}
|
|
381
|
+
>
|
|
382
|
+
{/* Header */}
|
|
383
|
+
<div
|
|
384
|
+
className="flex items-center justify-between px-4 py-3"
|
|
385
|
+
style={{ borderBottom: '1px solid var(--vscode-panel-border)' }}
|
|
386
|
+
>
|
|
387
|
+
<div className="flex items-center gap-3">
|
|
388
|
+
<h2 className="font-medium" style={{ color: 'var(--vscode-foreground)' }}>
|
|
389
|
+
Create Feature
|
|
390
|
+
</h2>
|
|
391
|
+
</div>
|
|
392
|
+
<button
|
|
393
|
+
onClick={handleCancel}
|
|
394
|
+
className="p-1.5 rounded transition-colors"
|
|
395
|
+
style={{ color: 'var(--vscode-descriptionForeground)' }}
|
|
396
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
|
|
397
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
398
|
+
>
|
|
399
|
+
<X size={18} />
|
|
400
|
+
</button>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
{/* Metadata */}
|
|
404
|
+
<div
|
|
405
|
+
className="flex flex-col py-0.5"
|
|
406
|
+
style={{
|
|
407
|
+
borderBottom: '1px solid var(--vscode-panel-border)',
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
<PropertyRow label="Status" icon={<CircleDot size={13} />}>
|
|
411
|
+
<Dropdown
|
|
412
|
+
value={status}
|
|
413
|
+
options={statusConfig.map(s => ({ value: s.value, label: s.label, dot: s.dot }))}
|
|
414
|
+
onChange={(v) => setStatus(v as FeatureStatus)}
|
|
415
|
+
/>
|
|
416
|
+
</PropertyRow>
|
|
417
|
+
{cardSettings.showPriorityBadges && (
|
|
418
|
+
<PropertyRow label="Priority" icon={<Signal size={13} />}>
|
|
419
|
+
<Dropdown
|
|
420
|
+
value={priority}
|
|
421
|
+
options={priorityConfig.map(p => ({ value: p.value, label: p.label, dot: p.dot }))}
|
|
422
|
+
onChange={(v) => setPriority(v as Priority)}
|
|
423
|
+
/>
|
|
424
|
+
</PropertyRow>
|
|
425
|
+
)}
|
|
426
|
+
{cardSettings.showAssignee && (
|
|
427
|
+
<PropertyRow label="Assignee" icon={<User size={13} />}>
|
|
428
|
+
<AssigneeInput value={assignee} onChange={setAssignee} />
|
|
429
|
+
</PropertyRow>
|
|
430
|
+
)}
|
|
431
|
+
{cardSettings.showDueDate && (
|
|
432
|
+
<PropertyRow label="Due date" icon={<Calendar size={13} />}>
|
|
433
|
+
<DatePicker value={dueDate} onChange={setDueDate} />
|
|
434
|
+
</PropertyRow>
|
|
435
|
+
)}
|
|
436
|
+
{cardSettings.showLabels && (
|
|
437
|
+
<PropertyRow label="Labels" icon={<Tag size={13} />}>
|
|
438
|
+
<LabelInput labels={labels} onChange={setLabels} />
|
|
439
|
+
</PropertyRow>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
{/* Content */}
|
|
444
|
+
<div className="flex-1 overflow-auto p-4">
|
|
445
|
+
<textarea
|
|
446
|
+
ref={inputRef}
|
|
447
|
+
value={title}
|
|
448
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
449
|
+
placeholder="Feature title..."
|
|
450
|
+
className="w-full text-lg font-medium bg-transparent border-none outline-none resize-none mb-4"
|
|
451
|
+
style={{
|
|
452
|
+
color: 'var(--vscode-foreground)',
|
|
453
|
+
}}
|
|
454
|
+
rows={1}
|
|
455
|
+
onInput={(e) => {
|
|
456
|
+
const target = e.target as HTMLTextAreaElement
|
|
457
|
+
target.style.height = 'auto'
|
|
458
|
+
target.style.height = target.scrollHeight + 'px'
|
|
459
|
+
}}
|
|
460
|
+
onKeyDown={(e) => {
|
|
461
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
462
|
+
e.preventDefault()
|
|
463
|
+
}
|
|
464
|
+
}}
|
|
465
|
+
/>
|
|
466
|
+
<MarkdownEditor
|
|
467
|
+
value={description}
|
|
468
|
+
onChange={setDescription}
|
|
469
|
+
placeholder="Add a description..."
|
|
470
|
+
/>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
{/* Footer with Cancel / Save buttons */}
|
|
474
|
+
<div
|
|
475
|
+
className="flex items-center justify-between px-4 py-3"
|
|
476
|
+
style={{
|
|
477
|
+
borderTop: '1px solid var(--vscode-panel-border)',
|
|
478
|
+
background: 'var(--vscode-sideBar-background, var(--vscode-editor-background))',
|
|
479
|
+
}}
|
|
480
|
+
>
|
|
481
|
+
<p className="text-xs" style={{ color: 'var(--vscode-descriptionForeground)' }}>
|
|
482
|
+
<kbd
|
|
483
|
+
className="px-1.5 py-0.5 rounded text-[10px] font-mono"
|
|
484
|
+
style={{ background: 'var(--vscode-keybindingLabel-background, var(--vscode-badge-background))', color: 'var(--vscode-keybindingLabel-foreground, var(--vscode-foreground))', border: '1px solid var(--vscode-keybindingLabel-border, var(--vscode-panel-border))' }}
|
|
485
|
+
>Esc</kbd>{' '}cancel ·{' '}
|
|
486
|
+
<kbd
|
|
487
|
+
className="px-1.5 py-0.5 rounded text-[10px] font-mono"
|
|
488
|
+
style={{ background: 'var(--vscode-keybindingLabel-background, var(--vscode-badge-background))', color: 'var(--vscode-keybindingLabel-foreground, var(--vscode-foreground))', border: '1px solid var(--vscode-keybindingLabel-border, var(--vscode-panel-border))' }}
|
|
489
|
+
>⌘S</kbd>{' '}save
|
|
490
|
+
</p>
|
|
491
|
+
<div className="flex items-center gap-2">
|
|
492
|
+
<button
|
|
493
|
+
type="button"
|
|
494
|
+
onClick={handleCancel}
|
|
495
|
+
className="px-3 py-1.5 text-xs font-medium rounded transition-colors"
|
|
496
|
+
style={{
|
|
497
|
+
color: 'var(--vscode-foreground)',
|
|
498
|
+
background: 'transparent',
|
|
499
|
+
border: '1px solid var(--vscode-panel-border)',
|
|
500
|
+
}}
|
|
501
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
|
|
502
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
503
|
+
>
|
|
504
|
+
Cancel
|
|
505
|
+
</button>
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
onClick={handleSaveAndClose}
|
|
509
|
+
className="px-3 py-1.5 text-xs font-medium rounded transition-colors"
|
|
510
|
+
style={{
|
|
511
|
+
color: 'var(--vscode-button-foreground)',
|
|
512
|
+
background: 'var(--vscode-button-background)',
|
|
513
|
+
}}
|
|
514
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-button-hoverBackground)'}
|
|
515
|
+
onMouseLeave={e => e.currentTarget.style.background = 'var(--vscode-button-background)'}
|
|
516
|
+
>
|
|
517
|
+
Save
|
|
518
|
+
</button>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
)
|
|
524
|
+
}
|