@miketromba/issy-app 0.1.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/README.md +17 -0
- package/bunfig.toml +2 -0
- package/package.json +46 -0
- package/src/App.tsx +332 -0
- package/src/components/Badge.tsx +49 -0
- package/src/components/ConfirmModal.tsx +71 -0
- package/src/components/CreateIssueModal.tsx +176 -0
- package/src/components/EditIssueModal.tsx +183 -0
- package/src/components/FilterBar.tsx +182 -0
- package/src/components/IssueDetail.tsx +148 -0
- package/src/components/IssueList.tsx +75 -0
- package/src/components/QueryHelpModal.tsx +160 -0
- package/src/frontend.tsx +18 -0
- package/src/index.css +99 -0
- package/src/index.html +12 -0
- package/src/index.ts +164 -0
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @miketromba/issy-app
|
|
2
|
+
|
|
3
|
+
Local web UI + API server for issy.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun --cwd packages/ui run dev
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The server reads and writes issues from `.issues/` in your current working directory.
|
|
12
|
+
|
|
13
|
+
## Environment Variables
|
|
14
|
+
|
|
15
|
+
- `ISSUES_ROOT`: root directory to look for `.issues` (default: cwd)
|
|
16
|
+
- `ISSUES_DIR`: explicit issues directory (overrides `ISSUES_ROOT`)
|
|
17
|
+
- `ISSUES_PORT`: server port for the UI/API
|
package/bunfig.toml
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@miketromba/issy-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local web UI and API server for issy",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/miketromba/issy.git",
|
|
11
|
+
"directory": "packages/ui"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/miketromba/issy/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/miketromba/issy#readme",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "bun --hot src/index.ts",
|
|
22
|
+
"start": "bun src/index.ts"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.ts"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"bunfig.toml"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@miketromba/issy-core": "^0.1.0",
|
|
33
|
+
"@tailwindcss/typography": "^0.5.16",
|
|
34
|
+
"bun-plugin-tailwind": "^0.1.2",
|
|
35
|
+
"highlight.js": "^11.11.1",
|
|
36
|
+
"marked": "^17.0.1",
|
|
37
|
+
"react": "^19",
|
|
38
|
+
"react-dom": "^19",
|
|
39
|
+
"tailwindcss": "^4.1.11"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/react": "^19",
|
|
43
|
+
"@types/react-dom": "^19",
|
|
44
|
+
"@types/bun": "latest"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
|
2
|
+
import { IssueList } from './components/IssueList'
|
|
3
|
+
import { IssueDetail } from './components/IssueDetail'
|
|
4
|
+
import { QueryHelpModal } from './components/QueryHelpModal'
|
|
5
|
+
import { FilterBar } from './components/FilterBar'
|
|
6
|
+
import { CreateIssueModal } from './components/CreateIssueModal'
|
|
7
|
+
import { EditIssueModal } from './components/EditIssueModal'
|
|
8
|
+
import { ConfirmModal } from './components/ConfirmModal'
|
|
9
|
+
import type { Issue, IssueFrontmatter } from '@miketromba/issy-core'
|
|
10
|
+
import { filterByQuery } from '@miketromba/issy-core'
|
|
11
|
+
|
|
12
|
+
// Re-export types for components
|
|
13
|
+
export type { Issue, IssueFrontmatter }
|
|
14
|
+
|
|
15
|
+
interface FilterState {
|
|
16
|
+
query: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const STORAGE_KEY = 'issy-state'
|
|
20
|
+
|
|
21
|
+
function loadState(): { filters: FilterState; selectedIssueId: string | null } {
|
|
22
|
+
try {
|
|
23
|
+
const stored = localStorage.getItem(STORAGE_KEY)
|
|
24
|
+
if (stored) {
|
|
25
|
+
const parsed = JSON.parse(stored)
|
|
26
|
+
// Migrate old filter format to new query format
|
|
27
|
+
if (parsed.filters && !parsed.filters.query) {
|
|
28
|
+
const oldFilters = parsed.filters
|
|
29
|
+
const queryParts: string[] = []
|
|
30
|
+
if (oldFilters.status)
|
|
31
|
+
queryParts.push(`is:${oldFilters.status}`)
|
|
32
|
+
if (oldFilters.priority)
|
|
33
|
+
queryParts.push(`priority:${oldFilters.priority}`)
|
|
34
|
+
if (oldFilters.type) queryParts.push(`type:${oldFilters.type}`)
|
|
35
|
+
if (oldFilters.search) queryParts.push(oldFilters.search)
|
|
36
|
+
return {
|
|
37
|
+
filters: { query: queryParts.join(' ') || 'is:open' },
|
|
38
|
+
selectedIssueId: parsed.selectedIssueId || null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return parsed
|
|
42
|
+
}
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error('Failed to load state from localStorage:', e)
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
filters: { query: 'is:open' }, // Default to showing open issues
|
|
48
|
+
selectedIssueId: null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function saveState(filters: FilterState, selectedIssueId: string | null) {
|
|
53
|
+
try {
|
|
54
|
+
localStorage.setItem(
|
|
55
|
+
STORAGE_KEY,
|
|
56
|
+
JSON.stringify({ filters, selectedIssueId })
|
|
57
|
+
)
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.error('Failed to save state to localStorage:', e)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function App() {
|
|
64
|
+
const [issues, setIssues] = useState<Issue[]>([])
|
|
65
|
+
const [loading, setLoading] = useState(true)
|
|
66
|
+
const [error, setError] = useState<string | null>(null)
|
|
67
|
+
|
|
68
|
+
const initialState = loadState()
|
|
69
|
+
const [filters, setFilters] = useState<FilterState>(initialState.filters)
|
|
70
|
+
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(
|
|
71
|
+
initialState.selectedIssueId
|
|
72
|
+
)
|
|
73
|
+
const [showHelp, setShowHelp] = useState(false)
|
|
74
|
+
const [showCreate, setShowCreate] = useState(false)
|
|
75
|
+
const [showEdit, setShowEdit] = useState(false)
|
|
76
|
+
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
|
77
|
+
const [showReopenConfirm, setShowReopenConfirm] = useState(false)
|
|
78
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
79
|
+
const [isDeleting, setIsDeleting] = useState(false)
|
|
80
|
+
const [actionLoading, setActionLoading] = useState(false)
|
|
81
|
+
|
|
82
|
+
// Fetch issues function - reusable for refresh
|
|
83
|
+
const fetchIssues = useCallback(async (showLoading = false) => {
|
|
84
|
+
try {
|
|
85
|
+
if (showLoading) setLoading(true)
|
|
86
|
+
const response = await fetch('/api/issues')
|
|
87
|
+
if (!response.ok) throw new Error('Failed to fetch issues')
|
|
88
|
+
const data = await response.json()
|
|
89
|
+
setIssues(data)
|
|
90
|
+
setError(null)
|
|
91
|
+
} catch (e) {
|
|
92
|
+
setError(e instanceof Error ? e.message : 'Unknown error')
|
|
93
|
+
} finally {
|
|
94
|
+
setLoading(false)
|
|
95
|
+
}
|
|
96
|
+
}, [])
|
|
97
|
+
|
|
98
|
+
// Initial fetch
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
fetchIssues(true)
|
|
101
|
+
}, [fetchIssues])
|
|
102
|
+
|
|
103
|
+
// Auto-refresh when window regains focus or tab becomes visible
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const handleVisibilityChange = () => {
|
|
106
|
+
if (document.visibilityState === 'visible') {
|
|
107
|
+
fetchIssues()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const handleFocus = () => {
|
|
112
|
+
fetchIssues()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
116
|
+
window.addEventListener('focus', handleFocus)
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
120
|
+
window.removeEventListener('focus', handleFocus)
|
|
121
|
+
}
|
|
122
|
+
}, [fetchIssues])
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
saveState(filters, selectedIssueId)
|
|
126
|
+
}, [filters, selectedIssueId])
|
|
127
|
+
|
|
128
|
+
const handleSelectIssue = useCallback((id: string | null) => {
|
|
129
|
+
setSelectedIssueId(id)
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
const handleCloseIssue = useCallback(async () => {
|
|
133
|
+
if (!selectedIssueId || actionLoading) return
|
|
134
|
+
setActionLoading(true)
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(`/api/issues/${selectedIssueId}/close`, { method: 'POST' })
|
|
137
|
+
if (!response.ok) throw new Error('Failed to close issue')
|
|
138
|
+
setShowCloseConfirm(false)
|
|
139
|
+
await fetchIssues()
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.error('Failed to close issue:', e)
|
|
142
|
+
} finally {
|
|
143
|
+
setActionLoading(false)
|
|
144
|
+
}
|
|
145
|
+
}, [selectedIssueId, actionLoading, fetchIssues])
|
|
146
|
+
|
|
147
|
+
const handleReopenIssue = useCallback(async () => {
|
|
148
|
+
if (!selectedIssueId || actionLoading) return
|
|
149
|
+
setActionLoading(true)
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetch(`/api/issues/${selectedIssueId}/reopen`, { method: 'POST' })
|
|
152
|
+
if (!response.ok) throw new Error('Failed to reopen issue')
|
|
153
|
+
setShowReopenConfirm(false)
|
|
154
|
+
await fetchIssues()
|
|
155
|
+
} catch (e) {
|
|
156
|
+
console.error('Failed to reopen issue:', e)
|
|
157
|
+
} finally {
|
|
158
|
+
setActionLoading(false)
|
|
159
|
+
}
|
|
160
|
+
}, [selectedIssueId, actionLoading, fetchIssues])
|
|
161
|
+
|
|
162
|
+
const handleDeleteIssue = useCallback(async () => {
|
|
163
|
+
if (!selectedIssueId || isDeleting) return
|
|
164
|
+
setIsDeleting(true)
|
|
165
|
+
try {
|
|
166
|
+
const response = await fetch(`/api/issues/${selectedIssueId}/delete`, { method: 'DELETE' })
|
|
167
|
+
if (!response.ok) throw new Error('Failed to delete issue')
|
|
168
|
+
setShowDeleteConfirm(false)
|
|
169
|
+
setSelectedIssueId(null)
|
|
170
|
+
await fetchIssues()
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.error('Failed to delete issue:', e)
|
|
173
|
+
} finally {
|
|
174
|
+
setIsDeleting(false)
|
|
175
|
+
}
|
|
176
|
+
}, [selectedIssueId, isDeleting, fetchIssues])
|
|
177
|
+
|
|
178
|
+
const filteredIssues = useMemo(() => {
|
|
179
|
+
return filterByQuery(issues, filters.query)
|
|
180
|
+
}, [issues, filters.query])
|
|
181
|
+
|
|
182
|
+
const selectedIssue = selectedIssueId
|
|
183
|
+
? issues.find(i => i.id === selectedIssueId) || null
|
|
184
|
+
: null
|
|
185
|
+
|
|
186
|
+
if (loading) {
|
|
187
|
+
return (
|
|
188
|
+
<div className="flex items-center justify-center h-screen bg-background text-text-muted">
|
|
189
|
+
Loading issues...
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (error) {
|
|
195
|
+
return (
|
|
196
|
+
<div className="flex items-center justify-center h-screen bg-background text-red-400">
|
|
197
|
+
Error: {error}
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div className="flex h-screen bg-background text-text-primary font-sans text-sm leading-relaxed">
|
|
204
|
+
{/* Sidebar - hidden on mobile when issue is selected */}
|
|
205
|
+
<aside className={`w-full md:w-[380px] md:min-w-[380px] border-r border-border flex flex-col h-screen bg-background ${
|
|
206
|
+
selectedIssue ? 'hidden md:flex' : 'flex'
|
|
207
|
+
}`}>
|
|
208
|
+
<div className="p-4 md:p-5 border-b border-border">
|
|
209
|
+
<div className="flex items-center justify-between mb-4">
|
|
210
|
+
<h1 className="text-lg font-semibold text-text-primary">
|
|
211
|
+
issy
|
|
212
|
+
</h1>
|
|
213
|
+
<button
|
|
214
|
+
onClick={() => setShowCreate(true)}
|
|
215
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-text-secondary hover:text-text-primary bg-surface hover:bg-surface-elevated border border-border rounded-lg transition-colors"
|
|
216
|
+
>
|
|
217
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
218
|
+
<path d="M12 5v14M5 12h14" />
|
|
219
|
+
</svg>
|
|
220
|
+
New
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div className="flex items-center bg-surface border border-border rounded-lg focus-within:border-accent transition-colors">
|
|
225
|
+
<input
|
|
226
|
+
type="text"
|
|
227
|
+
placeholder="Filter: is:open priority:high..."
|
|
228
|
+
value={filters.query}
|
|
229
|
+
onChange={e => setFilters({ query: e.target.value })}
|
|
230
|
+
className="flex-1 px-3.5 py-2.5 bg-transparent text-text-primary text-sm placeholder:text-text-muted focus:outline-none"
|
|
231
|
+
/>
|
|
232
|
+
<button
|
|
233
|
+
onClick={() => setShowHelp(true)}
|
|
234
|
+
aria-label="Query syntax help"
|
|
235
|
+
title="Query syntax help"
|
|
236
|
+
className="w-10 h-[38px] shrink-0 flex items-center justify-center border-l border-border rounded-r-lg text-text-muted text-sm font-medium cursor-pointer transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
|
237
|
+
>
|
|
238
|
+
?
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div className="mt-3">
|
|
243
|
+
<FilterBar
|
|
244
|
+
query={filters.query}
|
|
245
|
+
onQueryChange={(query) => setFilters({ query })}
|
|
246
|
+
issues={issues}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
|
252
|
+
<IssueList
|
|
253
|
+
issues={filteredIssues}
|
|
254
|
+
selectedId={selectedIssueId}
|
|
255
|
+
onSelect={handleSelectIssue}
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div className="px-4 md:px-5 py-3 border-t border-border text-xs text-text-muted">
|
|
260
|
+
{filteredIssues.length} of {issues.length} issues
|
|
261
|
+
</div>
|
|
262
|
+
</aside>
|
|
263
|
+
|
|
264
|
+
{/* Main content - full width on mobile when issue selected, hidden when no issue on mobile */}
|
|
265
|
+
<main className={`flex-1 h-screen overflow-y-auto bg-background custom-scrollbar ${
|
|
266
|
+
selectedIssue ? 'block' : 'hidden md:block'
|
|
267
|
+
}`}>
|
|
268
|
+
{selectedIssue ? (
|
|
269
|
+
<IssueDetail
|
|
270
|
+
issue={selectedIssue}
|
|
271
|
+
onBack={() => handleSelectIssue(null)}
|
|
272
|
+
onEdit={() => setShowEdit(true)}
|
|
273
|
+
onClose={() => setShowCloseConfirm(true)}
|
|
274
|
+
onReopen={() => setShowReopenConfirm(true)}
|
|
275
|
+
onDelete={() => setShowDeleteConfirm(true)}
|
|
276
|
+
/>
|
|
277
|
+
) : (
|
|
278
|
+
<div className="flex items-center justify-center h-full text-text-muted text-[15px]">
|
|
279
|
+
Select an issue to view details
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
</main>
|
|
283
|
+
|
|
284
|
+
{/* Modals */}
|
|
285
|
+
<QueryHelpModal isOpen={showHelp} onClose={() => setShowHelp(false)} />
|
|
286
|
+
|
|
287
|
+
<CreateIssueModal
|
|
288
|
+
isOpen={showCreate}
|
|
289
|
+
onClose={() => setShowCreate(false)}
|
|
290
|
+
onCreated={fetchIssues}
|
|
291
|
+
/>
|
|
292
|
+
|
|
293
|
+
<EditIssueModal
|
|
294
|
+
issue={selectedIssue}
|
|
295
|
+
isOpen={showEdit}
|
|
296
|
+
onClose={() => setShowEdit(false)}
|
|
297
|
+
onUpdated={fetchIssues}
|
|
298
|
+
/>
|
|
299
|
+
|
|
300
|
+
<ConfirmModal
|
|
301
|
+
isOpen={showCloseConfirm}
|
|
302
|
+
title="Close Issue"
|
|
303
|
+
message={`Are you sure you want to close issue #${selectedIssue?.id}?`}
|
|
304
|
+
confirmText="Close Issue"
|
|
305
|
+
onConfirm={handleCloseIssue}
|
|
306
|
+
onCancel={() => setShowCloseConfirm(false)}
|
|
307
|
+
isLoading={actionLoading}
|
|
308
|
+
/>
|
|
309
|
+
|
|
310
|
+
<ConfirmModal
|
|
311
|
+
isOpen={showReopenConfirm}
|
|
312
|
+
title="Reopen Issue"
|
|
313
|
+
message={`Are you sure you want to reopen issue #${selectedIssue?.id}?`}
|
|
314
|
+
confirmText="Reopen Issue"
|
|
315
|
+
onConfirm={handleReopenIssue}
|
|
316
|
+
onCancel={() => setShowReopenConfirm(false)}
|
|
317
|
+
isLoading={actionLoading}
|
|
318
|
+
/>
|
|
319
|
+
|
|
320
|
+
<ConfirmModal
|
|
321
|
+
isOpen={showDeleteConfirm}
|
|
322
|
+
title="Delete Issue"
|
|
323
|
+
message={`Are you sure you want to permanently delete issue #${selectedIssue?.id}? This action cannot be undone.`}
|
|
324
|
+
confirmText="Delete"
|
|
325
|
+
confirmVariant="danger"
|
|
326
|
+
onConfirm={handleDeleteIssue}
|
|
327
|
+
onCancel={() => setShowDeleteConfirm(false)}
|
|
328
|
+
isLoading={isDeleting}
|
|
329
|
+
/>
|
|
330
|
+
</div>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
interface BadgeProps {
|
|
2
|
+
variant: 'status' | 'priority' | 'type' | 'label';
|
|
3
|
+
value: string;
|
|
4
|
+
className?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const statusStyles: Record<string, string> = {
|
|
8
|
+
open: "bg-green-500/15 text-green-500",
|
|
9
|
+
closed: "bg-gray-500/15 text-gray-400",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const priorityStyles: Record<string, string> = {
|
|
13
|
+
high: "bg-priority-high/15 text-red-400",
|
|
14
|
+
medium: "bg-priority-medium/15 text-amber-400",
|
|
15
|
+
low: "bg-priority-low/15 text-green-400",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const typeStyles: Record<string, string> = {
|
|
19
|
+
bug: "bg-red-500/15 text-red-400",
|
|
20
|
+
feature: "bg-blue-500/15 text-blue-400",
|
|
21
|
+
enhancement: "bg-purple-500/15 text-purple-400",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function Badge({ variant, value, className = '' }: BadgeProps) {
|
|
25
|
+
const normalizedValue = value.toLowerCase();
|
|
26
|
+
|
|
27
|
+
let style = '';
|
|
28
|
+
|
|
29
|
+
switch (variant) {
|
|
30
|
+
case 'status':
|
|
31
|
+
style = statusStyles[normalizedValue] || statusStyles.open;
|
|
32
|
+
break;
|
|
33
|
+
case 'priority':
|
|
34
|
+
style = priorityStyles[normalizedValue] || '';
|
|
35
|
+
break;
|
|
36
|
+
case 'type':
|
|
37
|
+
style = typeStyles[normalizedValue] || "bg-purple-500/15 text-purple-400";
|
|
38
|
+
break;
|
|
39
|
+
case 'label':
|
|
40
|
+
style = "bg-surface-elevated text-text-secondary";
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<span className={`inline-block px-2 py-0.5 rounded text-[11px] font-medium ${style} ${className}`}>
|
|
46
|
+
{normalizedValue}
|
|
47
|
+
</span>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
interface ConfirmModalProps {
|
|
4
|
+
isOpen: boolean
|
|
5
|
+
title: string
|
|
6
|
+
message: string
|
|
7
|
+
confirmText?: string
|
|
8
|
+
confirmVariant?: 'danger' | 'default'
|
|
9
|
+
onConfirm: () => void
|
|
10
|
+
onCancel: () => void
|
|
11
|
+
isLoading?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ConfirmModal({
|
|
15
|
+
isOpen,
|
|
16
|
+
title,
|
|
17
|
+
message,
|
|
18
|
+
confirmText = 'Confirm',
|
|
19
|
+
confirmVariant = 'default',
|
|
20
|
+
onConfirm,
|
|
21
|
+
onCancel,
|
|
22
|
+
isLoading = false,
|
|
23
|
+
}: ConfirmModalProps) {
|
|
24
|
+
if (!isOpen) return null
|
|
25
|
+
|
|
26
|
+
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
27
|
+
if (e.target === e.currentTarget && !isLoading) {
|
|
28
|
+
onCancel()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const confirmStyles = confirmVariant === 'danger'
|
|
33
|
+
? 'bg-red-500 hover:bg-red-600 text-white'
|
|
34
|
+
: 'bg-accent hover:bg-accent-hover text-white'
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
|
39
|
+
onClick={handleBackdropClick}
|
|
40
|
+
>
|
|
41
|
+
<div className="bg-surface-elevated border border-border rounded-xl w-full max-w-sm shadow-2xl">
|
|
42
|
+
<div className="px-5 py-4 border-b border-border">
|
|
43
|
+
<h2 className="text-lg font-semibold text-text-primary">{title}</h2>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div className="p-5">
|
|
47
|
+
<p className="text-sm text-text-secondary mb-6">{message}</p>
|
|
48
|
+
|
|
49
|
+
<div className="flex justify-end gap-3">
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={onCancel}
|
|
53
|
+
disabled={isLoading}
|
|
54
|
+
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
|
|
55
|
+
>
|
|
56
|
+
Cancel
|
|
57
|
+
</button>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={onConfirm}
|
|
61
|
+
disabled={isLoading}
|
|
62
|
+
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50 ${confirmStyles}`}
|
|
63
|
+
>
|
|
64
|
+
{isLoading ? 'Processing...' : confirmText}
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
interface CreateIssueModalProps {
|
|
4
|
+
isOpen: boolean
|
|
5
|
+
onClose: () => void
|
|
6
|
+
onCreated: () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function CreateIssueModal({ isOpen, onClose, onCreated }: CreateIssueModalProps) {
|
|
10
|
+
const [title, setTitle] = useState('')
|
|
11
|
+
const [description, setDescription] = useState('')
|
|
12
|
+
const [type, setType] = useState<'bug' | 'improvement'>('improvement')
|
|
13
|
+
const [priority, setPriority] = useState<'high' | 'medium' | 'low'>('medium')
|
|
14
|
+
const [labels, setLabels] = useState('')
|
|
15
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
16
|
+
const [error, setError] = useState<string | null>(null)
|
|
17
|
+
|
|
18
|
+
if (!isOpen) return null
|
|
19
|
+
|
|
20
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
21
|
+
e.preventDefault()
|
|
22
|
+
|
|
23
|
+
if (!title.trim()) {
|
|
24
|
+
setError('Title is required')
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setIsSubmitting(true)
|
|
29
|
+
setError(null)
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch('/api/issues/create', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
title: title.trim(),
|
|
37
|
+
description: description.trim() || title.trim(),
|
|
38
|
+
type,
|
|
39
|
+
priority,
|
|
40
|
+
labels: labels.trim() || undefined,
|
|
41
|
+
}),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const data = await response.json()
|
|
46
|
+
throw new Error(data.error || 'Failed to create issue')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Reset form and close
|
|
50
|
+
setTitle('')
|
|
51
|
+
setDescription('')
|
|
52
|
+
setType('improvement')
|
|
53
|
+
setPriority('medium')
|
|
54
|
+
setLabels('')
|
|
55
|
+
onCreated()
|
|
56
|
+
onClose()
|
|
57
|
+
} catch (e) {
|
|
58
|
+
setError(e instanceof Error ? e.message : 'Unknown error')
|
|
59
|
+
} finally {
|
|
60
|
+
setIsSubmitting(false)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
65
|
+
if (e.target === e.currentTarget) {
|
|
66
|
+
onClose()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
|
73
|
+
onClick={handleBackdropClick}
|
|
74
|
+
>
|
|
75
|
+
<div className="bg-surface-elevated border border-border rounded-xl w-full max-w-lg shadow-2xl">
|
|
76
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
|
77
|
+
<h2 className="text-lg font-semibold text-text-primary">New Issue</h2>
|
|
78
|
+
<button
|
|
79
|
+
onClick={onClose}
|
|
80
|
+
className="text-text-muted hover:text-text-primary transition-colors"
|
|
81
|
+
>
|
|
82
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
83
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
84
|
+
</svg>
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
|
89
|
+
{error && (
|
|
90
|
+
<div className="px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
|
91
|
+
{error}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
<div>
|
|
96
|
+
<label className="block text-sm text-text-secondary mb-1.5">Title *</label>
|
|
97
|
+
<input
|
|
98
|
+
type="text"
|
|
99
|
+
value={title}
|
|
100
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
101
|
+
placeholder="Brief description of the issue"
|
|
102
|
+
autoFocus
|
|
103
|
+
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-text-primary text-sm placeholder:text-text-muted focus:outline-none focus:border-accent"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div>
|
|
108
|
+
<label className="block text-sm text-text-secondary mb-1.5">Description</label>
|
|
109
|
+
<input
|
|
110
|
+
type="text"
|
|
111
|
+
value={description}
|
|
112
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
113
|
+
placeholder="One-line summary (defaults to title)"
|
|
114
|
+
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-text-primary text-sm placeholder:text-text-muted focus:outline-none focus:border-accent"
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="grid grid-cols-2 gap-4">
|
|
119
|
+
<div>
|
|
120
|
+
<label className="block text-sm text-text-secondary mb-1.5">Type</label>
|
|
121
|
+
<select
|
|
122
|
+
value={type}
|
|
123
|
+
onChange={(e) => setType(e.target.value as 'bug' | 'improvement')}
|
|
124
|
+
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-text-primary text-sm focus:outline-none focus:border-accent"
|
|
125
|
+
>
|
|
126
|
+
<option value="improvement">Improvement</option>
|
|
127
|
+
<option value="bug">Bug</option>
|
|
128
|
+
</select>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div>
|
|
132
|
+
<label className="block text-sm text-text-secondary mb-1.5">Priority</label>
|
|
133
|
+
<select
|
|
134
|
+
value={priority}
|
|
135
|
+
onChange={(e) => setPriority(e.target.value as 'high' | 'medium' | 'low')}
|
|
136
|
+
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-text-primary text-sm focus:outline-none focus:border-accent"
|
|
137
|
+
>
|
|
138
|
+
<option value="high">High</option>
|
|
139
|
+
<option value="medium">Medium</option>
|
|
140
|
+
<option value="low">Low</option>
|
|
141
|
+
</select>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div>
|
|
146
|
+
<label className="block text-sm text-text-secondary mb-1.5">Labels</label>
|
|
147
|
+
<input
|
|
148
|
+
type="text"
|
|
149
|
+
value={labels}
|
|
150
|
+
onChange={(e) => setLabels(e.target.value)}
|
|
151
|
+
placeholder="Comma-separated: ui, backend, api"
|
|
152
|
+
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-text-primary text-sm placeholder:text-text-muted focus:outline-none focus:border-accent"
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="flex justify-end gap-3 pt-2">
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={onClose}
|
|
160
|
+
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
|
161
|
+
>
|
|
162
|
+
Cancel
|
|
163
|
+
</button>
|
|
164
|
+
<button
|
|
165
|
+
type="submit"
|
|
166
|
+
disabled={isSubmitting}
|
|
167
|
+
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
|
168
|
+
>
|
|
169
|
+
{isSubmitting ? 'Creating...' : 'Create Issue'}
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
</form>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
}
|