@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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react'
|
|
2
|
+
import type { Issue } from '../App'
|
|
3
|
+
|
|
4
|
+
interface EditIssueModalProps {
|
|
5
|
+
issue: Issue | null
|
|
6
|
+
isOpen: boolean
|
|
7
|
+
onClose: () => void
|
|
8
|
+
onUpdated: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function EditIssueModal({ issue, isOpen, onClose, onUpdated }: EditIssueModalProps) {
|
|
12
|
+
const [title, setTitle] = useState('')
|
|
13
|
+
const [description, setDescription] = useState('')
|
|
14
|
+
const [type, setType] = useState<'bug' | 'improvement'>('improvement')
|
|
15
|
+
const [priority, setPriority] = useState<'high' | 'medium' | 'low'>('medium')
|
|
16
|
+
const [labels, setLabels] = useState('')
|
|
17
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
18
|
+
const [error, setError] = useState<string | null>(null)
|
|
19
|
+
|
|
20
|
+
// Populate form when issue changes
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (issue) {
|
|
23
|
+
setTitle(issue.frontmatter.title || '')
|
|
24
|
+
setDescription(issue.frontmatter.description || '')
|
|
25
|
+
setType((issue.frontmatter.type as 'bug' | 'improvement') || 'improvement')
|
|
26
|
+
setPriority((issue.frontmatter.priority as 'high' | 'medium' | 'low') || 'medium')
|
|
27
|
+
setLabels(issue.frontmatter.labels || '')
|
|
28
|
+
}
|
|
29
|
+
}, [issue])
|
|
30
|
+
|
|
31
|
+
if (!isOpen || !issue) return null
|
|
32
|
+
|
|
33
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
|
|
36
|
+
if (!title.trim()) {
|
|
37
|
+
setError('Title is required')
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setIsSubmitting(true)
|
|
42
|
+
setError(null)
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(`/api/issues/${issue.id}`, {
|
|
46
|
+
method: 'PATCH',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
title: title.trim(),
|
|
50
|
+
description: description.trim() || title.trim(),
|
|
51
|
+
type,
|
|
52
|
+
priority,
|
|
53
|
+
labels: labels.trim() || undefined,
|
|
54
|
+
}),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const data = await response.json()
|
|
59
|
+
throw new Error(data.error || 'Failed to update issue')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
onUpdated()
|
|
63
|
+
onClose()
|
|
64
|
+
} catch (e) {
|
|
65
|
+
setError(e instanceof Error ? e.message : 'Unknown error')
|
|
66
|
+
} finally {
|
|
67
|
+
setIsSubmitting(false)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
72
|
+
if (e.target === e.currentTarget) {
|
|
73
|
+
onClose()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
|
80
|
+
onClick={handleBackdropClick}
|
|
81
|
+
>
|
|
82
|
+
<div className="bg-surface-elevated border border-border rounded-xl w-full max-w-lg shadow-2xl">
|
|
83
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
|
84
|
+
<h2 className="text-lg font-semibold text-text-primary">Edit Issue #{issue.id}</h2>
|
|
85
|
+
<button
|
|
86
|
+
onClick={onClose}
|
|
87
|
+
className="text-text-muted hover:text-text-primary transition-colors"
|
|
88
|
+
>
|
|
89
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
90
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
91
|
+
</svg>
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
|
96
|
+
{error && (
|
|
97
|
+
<div className="px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
|
98
|
+
{error}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
<div>
|
|
103
|
+
<label className="block text-sm text-text-secondary mb-1.5">Title *</label>
|
|
104
|
+
<input
|
|
105
|
+
type="text"
|
|
106
|
+
value={title}
|
|
107
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
108
|
+
placeholder="Brief description of the issue"
|
|
109
|
+
autoFocus
|
|
110
|
+
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"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div>
|
|
115
|
+
<label className="block text-sm text-text-secondary mb-1.5">Description</label>
|
|
116
|
+
<input
|
|
117
|
+
type="text"
|
|
118
|
+
value={description}
|
|
119
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
120
|
+
placeholder="One-line summary"
|
|
121
|
+
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"
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div className="grid grid-cols-2 gap-4">
|
|
126
|
+
<div>
|
|
127
|
+
<label className="block text-sm text-text-secondary mb-1.5">Type</label>
|
|
128
|
+
<select
|
|
129
|
+
value={type}
|
|
130
|
+
onChange={(e) => setType(e.target.value as 'bug' | 'improvement')}
|
|
131
|
+
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"
|
|
132
|
+
>
|
|
133
|
+
<option value="improvement">Improvement</option>
|
|
134
|
+
<option value="bug">Bug</option>
|
|
135
|
+
</select>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div>
|
|
139
|
+
<label className="block text-sm text-text-secondary mb-1.5">Priority</label>
|
|
140
|
+
<select
|
|
141
|
+
value={priority}
|
|
142
|
+
onChange={(e) => setPriority(e.target.value as 'high' | 'medium' | 'low')}
|
|
143
|
+
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"
|
|
144
|
+
>
|
|
145
|
+
<option value="high">High</option>
|
|
146
|
+
<option value="medium">Medium</option>
|
|
147
|
+
<option value="low">Low</option>
|
|
148
|
+
</select>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div>
|
|
153
|
+
<label className="block text-sm text-text-secondary mb-1.5">Labels</label>
|
|
154
|
+
<input
|
|
155
|
+
type="text"
|
|
156
|
+
value={labels}
|
|
157
|
+
onChange={(e) => setLabels(e.target.value)}
|
|
158
|
+
placeholder="Comma-separated: ui, backend, api"
|
|
159
|
+
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"
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="flex justify-end gap-3 pt-2">
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={onClose}
|
|
167
|
+
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
|
168
|
+
>
|
|
169
|
+
Cancel
|
|
170
|
+
</button>
|
|
171
|
+
<button
|
|
172
|
+
type="submit"
|
|
173
|
+
disabled={isSubmitting}
|
|
174
|
+
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
|
175
|
+
>
|
|
176
|
+
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
</form>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect, useMemo } from 'react'
|
|
2
|
+
import { parseQuery } from '@miketromba/issy-core'
|
|
3
|
+
import type { Issue } from '../App'
|
|
4
|
+
|
|
5
|
+
interface FilterBarProps {
|
|
6
|
+
query: string
|
|
7
|
+
onQueryChange: (query: string) => void
|
|
8
|
+
issues: Issue[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface DropdownProps {
|
|
12
|
+
label: string
|
|
13
|
+
value: string | null
|
|
14
|
+
options: { value: string; label: string }[]
|
|
15
|
+
onSelect: (value: string | null) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Dropdown({ label, value, options, onSelect }: DropdownProps) {
|
|
19
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
20
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
function handleClickOutside(e: MouseEvent) {
|
|
24
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
25
|
+
setIsOpen(false)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
29
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
30
|
+
}, [])
|
|
31
|
+
|
|
32
|
+
const selectedOption = options.find(o => o.value === value)
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div ref={ref} className="relative">
|
|
36
|
+
<button
|
|
37
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
38
|
+
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded border transition-colors ${
|
|
39
|
+
value
|
|
40
|
+
? 'bg-accent/10 border-accent/30 text-accent'
|
|
41
|
+
: 'bg-transparent border-border text-text-muted hover:text-text-secondary hover:border-border'
|
|
42
|
+
}`}
|
|
43
|
+
>
|
|
44
|
+
<span>{selectedOption ? selectedOption.label : label}</span>
|
|
45
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
46
|
+
<path d="M6 9l6 6 6-6" />
|
|
47
|
+
</svg>
|
|
48
|
+
</button>
|
|
49
|
+
|
|
50
|
+
{isOpen && (
|
|
51
|
+
<div className="absolute top-full left-0 mt-1 min-w-[120px] bg-surface-elevated border border-border rounded-lg shadow-lg z-50 py-1">
|
|
52
|
+
{value && (
|
|
53
|
+
<>
|
|
54
|
+
<button
|
|
55
|
+
onClick={() => { onSelect(null); setIsOpen(false) }}
|
|
56
|
+
className="w-full px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface hover:text-text-primary"
|
|
57
|
+
>
|
|
58
|
+
Clear
|
|
59
|
+
</button>
|
|
60
|
+
<div className="border-t border-border my-1" />
|
|
61
|
+
</>
|
|
62
|
+
)}
|
|
63
|
+
{options.map(option => (
|
|
64
|
+
<button
|
|
65
|
+
key={option.value}
|
|
66
|
+
onClick={() => { onSelect(option.value); setIsOpen(false) }}
|
|
67
|
+
className={`w-full px-3 py-1.5 text-left text-xs hover:bg-surface ${
|
|
68
|
+
value === option.value ? 'text-accent' : 'text-text-secondary hover:text-text-primary'
|
|
69
|
+
}`}
|
|
70
|
+
>
|
|
71
|
+
{option.label}
|
|
72
|
+
</button>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Sort options are fixed since they're not data-dependent
|
|
81
|
+
const SORT_OPTIONS = [
|
|
82
|
+
{ value: 'created', label: 'Newest' },
|
|
83
|
+
{ value: 'created-asc', label: 'Oldest' },
|
|
84
|
+
{ value: 'priority', label: 'Priority' },
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
// Priority sort order for consistent display
|
|
88
|
+
const PRIORITY_ORDER: Record<string, number> = { high: 0, medium: 1, low: 2 }
|
|
89
|
+
|
|
90
|
+
function capitalize(str: string): string {
|
|
91
|
+
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function extractUniqueValues(issues: Issue[], key: keyof Issue['frontmatter']): { value: string; label: string }[] {
|
|
95
|
+
const values = new Set<string>()
|
|
96
|
+
|
|
97
|
+
for (const issue of issues) {
|
|
98
|
+
const val = issue.frontmatter[key]
|
|
99
|
+
if (val && typeof val === 'string') {
|
|
100
|
+
values.add(val.toLowerCase())
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return Array.from(values)
|
|
105
|
+
.sort((a, b) => {
|
|
106
|
+
// Special sort for priority
|
|
107
|
+
if (key === 'priority') {
|
|
108
|
+
return (PRIORITY_ORDER[a] ?? 99) - (PRIORITY_ORDER[b] ?? 99)
|
|
109
|
+
}
|
|
110
|
+
return a.localeCompare(b)
|
|
111
|
+
})
|
|
112
|
+
.map(v => ({ value: v, label: capitalize(v) }))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function FilterBar({ query, onQueryChange, issues }: FilterBarProps) {
|
|
116
|
+
const parsed = parseQuery(query)
|
|
117
|
+
|
|
118
|
+
// Dynamically extract options from actual issue data
|
|
119
|
+
const statusOptions = useMemo(() => extractUniqueValues(issues, 'status'), [issues])
|
|
120
|
+
const priorityOptions = useMemo(() => extractUniqueValues(issues, 'priority'), [issues])
|
|
121
|
+
const typeOptions = useMemo(() => extractUniqueValues(issues, 'type'), [issues])
|
|
122
|
+
|
|
123
|
+
const updateQualifier = (key: string, value: string | null) => {
|
|
124
|
+
const newQualifiers = { ...parsed.qualifiers }
|
|
125
|
+
|
|
126
|
+
if (value === null) {
|
|
127
|
+
delete newQualifiers[key]
|
|
128
|
+
} else {
|
|
129
|
+
newQualifiers[key] = value
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Rebuild query string
|
|
133
|
+
const parts: string[] = []
|
|
134
|
+
|
|
135
|
+
// Add qualifiers in consistent order
|
|
136
|
+
if (newQualifiers.is) parts.push(`is:${newQualifiers.is}`)
|
|
137
|
+
if (newQualifiers.priority) parts.push(`priority:${newQualifiers.priority}`)
|
|
138
|
+
if (newQualifiers.type) parts.push(`type:${newQualifiers.type}`)
|
|
139
|
+
if (newQualifiers.label) parts.push(`label:${newQualifiers.label}`)
|
|
140
|
+
if (newQualifiers.sort) parts.push(`sort:${newQualifiers.sort}`)
|
|
141
|
+
|
|
142
|
+
// Add search text at the end
|
|
143
|
+
if (parsed.searchText) parts.push(parsed.searchText)
|
|
144
|
+
|
|
145
|
+
onQueryChange(parts.join(' '))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
150
|
+
{statusOptions.length > 0 && (
|
|
151
|
+
<Dropdown
|
|
152
|
+
label="Status"
|
|
153
|
+
value={parsed.qualifiers.is || null}
|
|
154
|
+
options={statusOptions}
|
|
155
|
+
onSelect={(v) => updateQualifier('is', v)}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
{priorityOptions.length > 0 && (
|
|
159
|
+
<Dropdown
|
|
160
|
+
label="Priority"
|
|
161
|
+
value={parsed.qualifiers.priority || null}
|
|
162
|
+
options={priorityOptions}
|
|
163
|
+
onSelect={(v) => updateQualifier('priority', v)}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
{typeOptions.length > 0 && (
|
|
167
|
+
<Dropdown
|
|
168
|
+
label="Type"
|
|
169
|
+
value={parsed.qualifiers.type || null}
|
|
170
|
+
options={typeOptions}
|
|
171
|
+
onSelect={(v) => updateQualifier('type', v)}
|
|
172
|
+
/>
|
|
173
|
+
)}
|
|
174
|
+
<Dropdown
|
|
175
|
+
label="Sort"
|
|
176
|
+
value={parsed.qualifiers.sort || null}
|
|
177
|
+
options={SORT_OPTIONS}
|
|
178
|
+
onSelect={(v) => updateQualifier('sort', v)}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { marked, Renderer } from "marked";
|
|
2
|
+
import hljs from "highlight.js";
|
|
3
|
+
import "highlight.js/styles/github-dark.css";
|
|
4
|
+
import type { Issue } from "../App";
|
|
5
|
+
import { Badge } from "./Badge";
|
|
6
|
+
import { formatDisplayDate, formatFullDate } from "@miketromba/issy-core";
|
|
7
|
+
|
|
8
|
+
interface IssueDetailProps {
|
|
9
|
+
issue: Issue;
|
|
10
|
+
onBack?: () => void;
|
|
11
|
+
onEdit?: () => void;
|
|
12
|
+
onClose?: () => void;
|
|
13
|
+
onReopen?: () => void;
|
|
14
|
+
onDelete?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Custom renderer with syntax highlighting
|
|
18
|
+
const renderer = new Renderer();
|
|
19
|
+
renderer.code = function({ text, lang }) {
|
|
20
|
+
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
|
|
21
|
+
const highlighted = hljs.highlight(text, { language }).value;
|
|
22
|
+
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
marked.setOptions({
|
|
26
|
+
gfm: true,
|
|
27
|
+
breaks: true,
|
|
28
|
+
renderer,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function IssueDetail({ issue, onBack, onEdit, onClose, onReopen, onDelete }: IssueDetailProps) {
|
|
32
|
+
const labels = issue.frontmatter.labels?.split(',').map(l => l.trim()).filter(Boolean) || [];
|
|
33
|
+
const isOpen = issue.frontmatter.status === 'open';
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="max-w-[800px] mx-auto px-4 md:px-10 py-6 md:py-8">
|
|
37
|
+
{/* Mobile back button */}
|
|
38
|
+
{onBack && (
|
|
39
|
+
<button
|
|
40
|
+
onClick={onBack}
|
|
41
|
+
className="md:hidden inline-flex items-center gap-1.5 mb-4 text-text-secondary text-sm"
|
|
42
|
+
>
|
|
43
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
44
|
+
<path d="M15 19l-7-7 7-7" />
|
|
45
|
+
</svg>
|
|
46
|
+
Back to issues
|
|
47
|
+
</button>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
<div className="mb-6">
|
|
51
|
+
<div className="flex items-start justify-between gap-3 mb-3">
|
|
52
|
+
<h1 className="text-xl md:text-2xl font-semibold text-text-primary leading-tight">
|
|
53
|
+
{issue.frontmatter.title || "Untitled Issue"}
|
|
54
|
+
<span className="font-normal text-text-muted ml-2">#{issue.id}</span>
|
|
55
|
+
</h1>
|
|
56
|
+
|
|
57
|
+
{/* Action buttons */}
|
|
58
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
59
|
+
{onEdit && (
|
|
60
|
+
<button
|
|
61
|
+
onClick={onEdit}
|
|
62
|
+
title="Edit issue"
|
|
63
|
+
className="p-2 text-text-muted hover:text-text-primary hover:bg-surface rounded-lg transition-colors"
|
|
64
|
+
>
|
|
65
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
66
|
+
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
67
|
+
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
68
|
+
</svg>
|
|
69
|
+
</button>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{onDelete && (
|
|
73
|
+
<button
|
|
74
|
+
onClick={onDelete}
|
|
75
|
+
title="Delete issue"
|
|
76
|
+
className="p-2 text-text-muted hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
|
77
|
+
>
|
|
78
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
79
|
+
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
80
|
+
</svg>
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{isOpen && onClose && (
|
|
85
|
+
<button
|
|
86
|
+
onClick={onClose}
|
|
87
|
+
title="Close issue"
|
|
88
|
+
className="ml-1 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"
|
|
89
|
+
>
|
|
90
|
+
Close
|
|
91
|
+
</button>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{!isOpen && onReopen && (
|
|
95
|
+
<button
|
|
96
|
+
onClick={onReopen}
|
|
97
|
+
title="Reopen issue"
|
|
98
|
+
className="ml-1 px-3 py-1.5 text-xs font-medium text-green-400 hover:text-green-300 bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 rounded-lg transition-colors"
|
|
99
|
+
>
|
|
100
|
+
Reopen
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{issue.frontmatter.description && (
|
|
107
|
+
<p className="text-[15px] text-text-secondary leading-relaxed mb-4">
|
|
108
|
+
{issue.frontmatter.description}
|
|
109
|
+
</p>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
113
|
+
{issue.frontmatter.priority && (
|
|
114
|
+
<Badge variant="priority" value={issue.frontmatter.priority} />
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{issue.frontmatter.status && (
|
|
118
|
+
<Badge variant="status" value={issue.frontmatter.status} />
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{issue.frontmatter.type && (
|
|
122
|
+
<Badge variant="type" value={issue.frontmatter.type} />
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{labels.map((label) => (
|
|
126
|
+
<Badge key={label} variant="label" value={label} />
|
|
127
|
+
))}
|
|
128
|
+
|
|
129
|
+
{issue.frontmatter.created && (
|
|
130
|
+
<span
|
|
131
|
+
className="text-xs text-text-muted"
|
|
132
|
+
title={formatFullDate(issue.frontmatter.created)}
|
|
133
|
+
>
|
|
134
|
+
{formatDisplayDate(issue.frontmatter.created)}
|
|
135
|
+
</span>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<hr className="border-0 border-t border-border my-6" />
|
|
141
|
+
|
|
142
|
+
<div
|
|
143
|
+
className="prose prose-invert prose-sm max-w-none prose-a:text-accent prose-pre:border-0 prose-pre:shadow-none prose-code:bg-surface-elevated prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none"
|
|
144
|
+
dangerouslySetInnerHTML={{ __html: marked.parse(issue.content) as string }}
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Issue } from "../App";
|
|
2
|
+
import { Badge } from "./Badge";
|
|
3
|
+
import { formatDisplayDate, formatFullDate } from "@miketromba/issy-core";
|
|
4
|
+
|
|
5
|
+
interface IssueListProps {
|
|
6
|
+
issues: Issue[];
|
|
7
|
+
selectedId: string | null;
|
|
8
|
+
onSelect: (id: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function IssueList({ issues, selectedId, onSelect }: IssueListProps) {
|
|
12
|
+
if (issues.length === 0) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="px-5 py-6 text-text-muted text-sm">
|
|
15
|
+
No issues found
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
{issues.map((issue) => {
|
|
23
|
+
const isSelected = issue.id === selectedId;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
key={issue.id}
|
|
28
|
+
onClick={() => onSelect(issue.id)}
|
|
29
|
+
className={`block w-full px-5 py-4 border-0 border-b border-border-subtle bg-transparent text-left cursor-pointer transition-colors hover:bg-surface ${
|
|
30
|
+
isSelected ? "bg-surface-elevated" : ""
|
|
31
|
+
}`}
|
|
32
|
+
>
|
|
33
|
+
<div className="flex items-baseline gap-2 mb-1.5">
|
|
34
|
+
<span className="text-sm font-medium text-text-primary leading-snug flex-1 min-w-0 line-clamp-2">
|
|
35
|
+
{issue.frontmatter.title || "Untitled"}
|
|
36
|
+
</span>
|
|
37
|
+
<span className="font-mono text-xs text-text-muted shrink-0 ml-1">
|
|
38
|
+
#{issue.id}
|
|
39
|
+
</span>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{issue.frontmatter.description && (
|
|
43
|
+
<div className="text-[13px] text-text-muted mb-2.5 line-clamp-1">
|
|
44
|
+
{issue.frontmatter.description}
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
49
|
+
{issue.frontmatter.priority && (
|
|
50
|
+
<Badge variant="priority" value={issue.frontmatter.priority} />
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
{issue.frontmatter.status && (
|
|
54
|
+
<Badge variant="status" value={issue.frontmatter.status} />
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{issue.frontmatter.type && (
|
|
58
|
+
<Badge variant="type" value={issue.frontmatter.type} />
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
{issue.frontmatter.created && (
|
|
62
|
+
<span
|
|
63
|
+
className="text-xs text-text-muted"
|
|
64
|
+
title={formatFullDate(issue.frontmatter.created)}
|
|
65
|
+
>
|
|
66
|
+
{formatDisplayDate(issue.frontmatter.created)}
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
})}
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
}
|