@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 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
@@ -0,0 +1,2 @@
1
+ [serve.static]
2
+ plugins = ["bun-plugin-tailwind"]
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
+ }