@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.
@@ -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
+ }