pr-review-agent 1.0.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 +80 -0
- package/agents/pr-reviewer.md +148 -0
- package/bin/install.js +277 -0
- package/commands/pr-review/review.md +65 -0
- package/commands/pr-review/setup.md +94 -0
- package/package.json +32 -0
- package/template/index.html +1003 -0
- package/template/serve.js +136 -0
- package/template/templates/review-plan.md +69 -0
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>PR Review Preview</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg-primary: #0d1117; --bg-secondary: #161b22; --bg-tertiary: #21262d;
|
|
10
|
+
--bg-hover: #1c2128; --bg-card: #161b22; --border: #30363d; --border-muted: #21262d;
|
|
11
|
+
--text-primary: #e6edf3; --text-secondary: #8b949e; --text-muted: #6e7681;
|
|
12
|
+
--accent-blue: #58a6ff; --accent-purple: #bc8cff; --accent-green: #3fb950;
|
|
13
|
+
--accent-yellow: #d29922; --accent-red: #f85149; --accent-orange: #db6d28;
|
|
14
|
+
--badge-critical-bg: rgba(248,81,73,.15); --badge-critical-text: #f85149; --badge-critical-border: rgba(248,81,73,.4);
|
|
15
|
+
--badge-warning-bg: rgba(210,153,34,.15); --badge-warning-text: #d29922; --badge-warning-border: rgba(210,153,34,.4);
|
|
16
|
+
--badge-suggestion-bg: rgba(88,166,255,.12); --badge-suggestion-text: #58a6ff; --badge-suggestion-border: rgba(88,166,255,.4);
|
|
17
|
+
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
|
18
|
+
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
|
19
|
+
--radius: 6px;
|
|
20
|
+
}
|
|
21
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
22
|
+
body { font-family: var(--font-sans); background: var(--bg-primary); color: var(--text-primary); line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
|
23
|
+
|
|
24
|
+
/* Header */
|
|
25
|
+
.header { background: var(--bg-secondary); border-bottom: 1px solid var(--border); padding: 16px 24px; position: sticky; top: 0; z-index: 100; backdrop-filter: blur(12px); }
|
|
26
|
+
.header-inner { max-width: 1280px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
27
|
+
.header-left { display: flex; align-items: center; gap: 12px; }
|
|
28
|
+
.header-logo { width: 32px; height: 32px; background: var(--accent-purple); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; color: #fff; }
|
|
29
|
+
.header-title { font-size: 16px; font-weight: 600; }
|
|
30
|
+
.header-subtitle { font-size: 13px; color: var(--text-secondary); }
|
|
31
|
+
.header-badge { display: inline-flex; align-items: center; gap: 6px; background: rgba(63,185,80,.15); color: var(--accent-green); border: 1px solid rgba(63,185,80,.4); padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
|
32
|
+
.header-badge::before { content: ''; width: 8px; height: 8px; background: var(--accent-green); border-radius: 50%; }
|
|
33
|
+
|
|
34
|
+
/* Layout */
|
|
35
|
+
.layout { max-width: 1280px; margin: 0 auto; display: grid; grid-template-columns: 280px 1fr; gap: 0; min-height: calc(100vh - 65px); }
|
|
36
|
+
.sidebar { border-right: 1px solid var(--border); padding: 20px 0; position: sticky; top: 65px; height: calc(100vh - 65px); overflow-y: auto; }
|
|
37
|
+
.sidebar-section { padding: 0 16px; margin-bottom: 24px; }
|
|
38
|
+
.sidebar-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-bottom: 8px; padding: 0 8px; display: flex; align-items: center; justify-content: space-between; }
|
|
39
|
+
.sidebar-section-title .sidebar-action { font-size: 16px; cursor: pointer; opacity: .5; transition: opacity .15s; text-transform: none; letter-spacing: 0; }
|
|
40
|
+
.sidebar-section-title .sidebar-action:hover { opacity: 1; }
|
|
41
|
+
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 16px; margin-bottom: 20px; }
|
|
42
|
+
.stat-card { background: var(--bg-tertiary); border: 1px solid var(--border-muted); border-radius: var(--radius); padding: 12px; text-align: center; }
|
|
43
|
+
.stat-value { font-size: 24px; font-weight: 700; line-height: 1.2; }
|
|
44
|
+
.stat-label { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
|
|
45
|
+
.stat-critical .stat-value { color: var(--accent-red); }
|
|
46
|
+
.stat-warning .stat-value { color: var(--accent-yellow); }
|
|
47
|
+
.stat-suggestion .stat-value { color: var(--accent-blue); }
|
|
48
|
+
.stat-total .stat-value { color: var(--text-primary); }
|
|
49
|
+
|
|
50
|
+
.filter-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-radius: var(--radius); cursor: pointer; font-size: 13px; transition: background .15s; position: relative; }
|
|
51
|
+
.filter-item:hover { background: var(--bg-hover); }
|
|
52
|
+
.filter-item.active { background: var(--bg-tertiary); color: var(--accent-blue); }
|
|
53
|
+
.filter-item-left { display: flex; align-items: center; gap: 8px; overflow: hidden; }
|
|
54
|
+
.filter-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
55
|
+
.filter-right { display: flex; align-items: center; gap: 2px; }
|
|
56
|
+
.filter-count { background: var(--bg-tertiary); color: var(--text-secondary); padding: 0 6px; border-radius: 10px; font-size: 11px; font-weight: 500; min-width: 20px; text-align: center; }
|
|
57
|
+
.filter-action { width: 22px; height: 22px; border: none; background: transparent; color: var(--text-muted); cursor: pointer; border-radius: 4px; display: none; align-items: center; justify-content: center; font-size: 11px; transition: all .1s; padding: 0; }
|
|
58
|
+
.filter-action:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
|
59
|
+
.filter-action.danger:hover { color: var(--accent-red); }
|
|
60
|
+
.filter-item:hover .filter-action { display: flex; }
|
|
61
|
+
|
|
62
|
+
.main { padding: 20px 24px; }
|
|
63
|
+
.main-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; gap: 12px; flex-wrap: wrap; }
|
|
64
|
+
.main-title { font-size: 18px; font-weight: 600; }
|
|
65
|
+
.main-controls { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
66
|
+
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: var(--radius); font-size: 12px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); transition: all .15s; white-space: nowrap; }
|
|
67
|
+
.btn:hover { background: var(--bg-hover); border-color: var(--text-muted); }
|
|
68
|
+
.btn-primary { background: rgba(88,166,255,.15); border-color: rgba(88,166,255,.4); color: var(--accent-blue); }
|
|
69
|
+
.btn-primary:hover { background: rgba(88,166,255,.25); }
|
|
70
|
+
.btn-success { background: rgba(63,185,80,.15); border-color: rgba(63,185,80,.4); color: var(--accent-green); }
|
|
71
|
+
.btn-success:hover { background: rgba(63,185,80,.25); }
|
|
72
|
+
.btn-danger { background: rgba(248,81,73,.1); border-color: rgba(248,81,73,.3); color: var(--accent-red); }
|
|
73
|
+
.btn-danger:hover { background: rgba(248,81,73,.2); }
|
|
74
|
+
.btn:disabled { opacity: .4; cursor: default; pointer-events: none; }
|
|
75
|
+
|
|
76
|
+
.search-wrapper { padding: 0 16px; margin-bottom: 16px; }
|
|
77
|
+
.search-input, .edit-input, .edit-textarea, .edit-select { width: 100%; padding: 6px 10px; font-size: 13px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); outline: none; transition: border-color .15s; font-family: var(--font-sans); }
|
|
78
|
+
.search-input:focus, .edit-input:focus, .edit-textarea:focus, .edit-select:focus { border-color: var(--accent-blue); }
|
|
79
|
+
.search-input::placeholder, .edit-input::placeholder, .edit-textarea::placeholder { color: var(--text-muted); }
|
|
80
|
+
.edit-textarea { min-height: 80px; resize: vertical; line-height: 1.5; }
|
|
81
|
+
.edit-select { cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238b949e' viewBox='0 0 16 16'%3E%3Cpath d='M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; padding-right: 28px; }
|
|
82
|
+
|
|
83
|
+
/* Comments */
|
|
84
|
+
.comment-group { margin-bottom: 24px; }
|
|
85
|
+
.comment-group-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; margin-bottom: 2px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0; font-size: 13px; cursor: pointer; }
|
|
86
|
+
.comment-group-header:hover { background: var(--bg-hover); }
|
|
87
|
+
.file-icon { color: var(--text-muted); font-size: 14px; }
|
|
88
|
+
.file-path { font-family: var(--font-mono); font-size: 12px; color: var(--accent-blue); flex: 1; }
|
|
89
|
+
.file-badge-count { font-size: 11px; padding: 0 6px; border-radius: 10px; font-weight: 500; background: var(--bg-tertiary); color: var(--text-secondary); }
|
|
90
|
+
|
|
91
|
+
.comment-card { border: 1px solid var(--border); border-top: none; padding: 16px; background: var(--bg-card); transition: background .15s; animation: fadeIn .2s ease-out; position: relative; }
|
|
92
|
+
.comment-card:last-child { border-radius: 0 0 var(--radius) var(--radius); }
|
|
93
|
+
.comment-card:hover { background: var(--bg-hover); }
|
|
94
|
+
.comment-card:hover .card-actions { opacity: 1; }
|
|
95
|
+
.comment-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
|
|
96
|
+
.severity-badge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .3px; }
|
|
97
|
+
.severity-critical { background: var(--badge-critical-bg); color: var(--badge-critical-text); border: 1px solid var(--badge-critical-border); }
|
|
98
|
+
.severity-warning { background: var(--badge-warning-bg); color: var(--badge-warning-text); border: 1px solid var(--badge-warning-border); }
|
|
99
|
+
.severity-suggestion { background: var(--badge-suggestion-bg); color: var(--badge-suggestion-text); border: 1px solid var(--badge-suggestion-border); }
|
|
100
|
+
.category-tag { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 20px; font-size: 11px; font-weight: 500; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-secondary); }
|
|
101
|
+
.category-tag .filter-dot { width: 6px; height: 6px; }
|
|
102
|
+
.comment-title { font-size: 14px; font-weight: 600; flex: 1; }
|
|
103
|
+
.comment-body { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
|
|
104
|
+
.comment-body code { font-family: var(--font-mono); font-size: 12px; background: rgba(110,118,129,.15); padding: 2px 6px; border-radius: 4px; color: var(--text-primary); }
|
|
105
|
+
.comment-body pre { background: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; margin: 8px 0; overflow-x: auto; font-size: 12px; line-height: 1.5; }
|
|
106
|
+
.comment-body pre code { background: none; padding: 0; }
|
|
107
|
+
.comment-body strong { color: var(--text-primary); }
|
|
108
|
+
.comment-body ul { padding-left: 20px; margin: 6px 0; }
|
|
109
|
+
.comment-body li { margin: 3px 0; }
|
|
110
|
+
.line-ref { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); margin-left: auto; }
|
|
111
|
+
|
|
112
|
+
/* Card hover actions */
|
|
113
|
+
.card-actions { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; opacity: 0; transition: opacity .15s; }
|
|
114
|
+
.card-action-btn { width: 28px; height: 28px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; transition: all .15s; }
|
|
115
|
+
.card-action-btn:hover { background: var(--bg-tertiary); color: var(--text-primary); border-color: var(--text-muted); }
|
|
116
|
+
.card-action-btn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); }
|
|
117
|
+
|
|
118
|
+
/* Edit form — card takeover */
|
|
119
|
+
.comment-card.editing { border-color: var(--accent-blue); background: var(--bg-primary); padding: 0; overflow: hidden; }
|
|
120
|
+
.edit-form { display: flex; flex-direction: column; }
|
|
121
|
+
.edit-form-header { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: rgba(88,166,255,.06); border-bottom: 1px solid rgba(88,166,255,.15); }
|
|
122
|
+
.edit-form-header-title { font-size: 13px; font-weight: 600; color: var(--accent-blue); flex: 1; }
|
|
123
|
+
.edit-form-body { padding: 16px; display: flex; flex-direction: column; gap: 14px; }
|
|
124
|
+
.edit-section { display: flex; flex-direction: column; gap: 10px; }
|
|
125
|
+
.edit-section-label { font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: .8px; padding-bottom: 4px; border-bottom: 1px solid var(--border-muted); }
|
|
126
|
+
.edit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
127
|
+
.edit-field { display: flex; flex-direction: column; gap: 4px; }
|
|
128
|
+
.edit-field.full { grid-column: 1 / -1; }
|
|
129
|
+
.edit-field > label { font-size: 11px; font-weight: 600; color: var(--text-secondary); }
|
|
130
|
+
.edit-field > .edit-input, .edit-field > .edit-select, .edit-field > .edit-textarea { width: 100%; }
|
|
131
|
+
.edit-textarea { min-height: 100px; resize: vertical; line-height: 1.5; font-family: var(--font-mono); font-size: 12px; }
|
|
132
|
+
.edit-preview { padding: 12px; background: var(--bg-secondary); border: 1px solid var(--border-muted); border-radius: var(--radius); min-height: 48px; font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
|
|
133
|
+
.edit-preview:empty::before { content: 'Preview will appear here...'; color: var(--text-muted); font-style: italic; }
|
|
134
|
+
.edit-preview code { font-family: var(--font-mono); font-size: 12px; background: rgba(110,118,129,.15); padding: 2px 6px; border-radius: 4px; color: var(--text-primary); }
|
|
135
|
+
.edit-preview pre { background: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; margin: 6px 0; overflow-x: auto; font-size: 12px; }
|
|
136
|
+
.edit-preview ul { padding-left: 20px; margin: 4px 0; }
|
|
137
|
+
.edit-preview li { margin: 2px 0; }
|
|
138
|
+
.edit-preview strong { color: var(--text-primary); }
|
|
139
|
+
.edit-tab-bar { display: flex; border-bottom: 1px solid var(--border-muted); gap: 0; }
|
|
140
|
+
.edit-tab { padding: 6px 14px; font-size: 12px; font-weight: 500; color: var(--text-muted); cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; background: none; border-top: none; border-left: none; border-right: none; }
|
|
141
|
+
.edit-tab:hover { color: var(--text-secondary); }
|
|
142
|
+
.edit-tab.active { color: var(--accent-blue); border-bottom-color: var(--accent-blue); }
|
|
143
|
+
.edit-form-footer { display: flex; gap: 8px; padding: 12px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border); }
|
|
144
|
+
.edit-form-footer .btn-danger { margin-right: auto; }
|
|
145
|
+
|
|
146
|
+
.collapse-icon { transition: transform .2s; font-size: 12px; color: var(--text-muted); }
|
|
147
|
+
.collapsed .collapse-icon { transform: rotate(-90deg); }
|
|
148
|
+
.collapsed + .comment-list { display: none; }
|
|
149
|
+
|
|
150
|
+
.summary-bar { display: flex; align-items: center; gap: 16px; padding: 12px 16px; margin-bottom: 20px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); font-size: 13px; }
|
|
151
|
+
.summary-bar-label { color: var(--text-secondary); font-weight: 500; }
|
|
152
|
+
.progress-bar { flex: 1; height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; display: flex; }
|
|
153
|
+
.progress-segment { height: 100%; transition: width .3s; }
|
|
154
|
+
|
|
155
|
+
/* Modal — accessible dialog */
|
|
156
|
+
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(1,4,9,.7); z-index: 200; align-items: flex-start; justify-content: center; padding-top: min(12vh, 120px); backdrop-filter: blur(6px); opacity: 0; transition: opacity .15s ease; }
|
|
157
|
+
.modal-overlay.open { display: flex; opacity: 1; }
|
|
158
|
+
.modal { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 12px; width: 100%; max-width: 520px; box-shadow: 0 24px 64px rgba(0,0,0,.5), 0 0 0 1px rgba(255,255,255,.04) inset; transform: translateY(8px) scale(.98); opacity: 0; transition: transform .2s ease, opacity .2s ease; overflow: hidden; }
|
|
159
|
+
.modal-overlay.open .modal { transform: translateY(0) scale(1); opacity: 1; }
|
|
160
|
+
.modal-header { padding: 20px 24px 0; display: flex; align-items: center; justify-content: space-between; }
|
|
161
|
+
.modal-title { font-size: 16px; font-weight: 600; }
|
|
162
|
+
.modal-close { width: 32px; height: 32px; border-radius: var(--radius); border: none; background: transparent; color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; transition: all .15s; }
|
|
163
|
+
.modal-close:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
|
164
|
+
.modal-body { padding: 20px 24px; }
|
|
165
|
+
.modal-field { margin-bottom: 16px; }
|
|
166
|
+
.modal-field:last-child { margin-bottom: 0; }
|
|
167
|
+
.modal-field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
|
168
|
+
.modal-field .field-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
|
169
|
+
.modal-field .field-error { font-size: 11px; color: var(--accent-red); margin-top: 4px; display: none; }
|
|
170
|
+
.modal-field.has-error .field-error { display: block; }
|
|
171
|
+
.modal-field.has-error .edit-input { border-color: var(--accent-red); }
|
|
172
|
+
.modal-footer { padding: 16px 24px; background: var(--bg-primary); border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; }
|
|
173
|
+
.color-picker-row { display: flex; gap: 10px; align-items: center; }
|
|
174
|
+
.color-swatch { width: 40px; height: 36px; border: 2px solid var(--border); border-radius: var(--radius); cursor: pointer; padding: 0; flex-shrink: 0; transition: border-color .15s; }
|
|
175
|
+
.color-swatch:hover { border-color: var(--text-muted); }
|
|
176
|
+
.color-swatch:focus { border-color: var(--accent-blue); outline: none; }
|
|
177
|
+
.color-preview { width: 10px; height: 10px; border-radius: 50%; display: inline-block; vertical-align: middle; margin-right: 6px; border: 1px solid rgba(255,255,255,.1); }
|
|
178
|
+
/* Persist status in header */
|
|
179
|
+
.save-status { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; }
|
|
180
|
+
.save-status-connected { background: rgba(63,185,80,.12); color: var(--accent-green); }
|
|
181
|
+
.save-status-offline { background: rgba(248,81,73,.1); color: var(--accent-red); }
|
|
182
|
+
|
|
183
|
+
/* Empty states */
|
|
184
|
+
.empty-state { text-align: center; padding: 80px 32px; color: var(--text-muted); }
|
|
185
|
+
.empty-state-icon { font-size: 56px; margin-bottom: 16px; opacity: .4; line-height: 1; }
|
|
186
|
+
.empty-state-title { font-size: 18px; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; }
|
|
187
|
+
.empty-state-text { font-size: 14px; max-width: 420px; margin: 0 auto; line-height: 1.6; }
|
|
188
|
+
.empty-state-hint { margin-top: 20px; padding: 12px 16px; background: var(--bg-tertiary); border: 1px solid var(--border-muted); border-radius: var(--radius); display: inline-block; font-family: var(--font-mono); font-size: 13px; color: var(--accent-blue); }
|
|
189
|
+
|
|
190
|
+
/* Toast */
|
|
191
|
+
.toast { position: fixed; bottom: 24px; right: 24px; padding: 10px 16px; border-radius: var(--radius); font-size: 13px; font-weight: 500; z-index: 300; animation: slideUp .2s ease-out; }
|
|
192
|
+
.toast-success { background: rgba(63,185,80,.15); border: 1px solid rgba(63,185,80,.4); color: var(--accent-green); }
|
|
193
|
+
.toast-error { background: rgba(248,81,73,.15); border: 1px solid rgba(248,81,73,.4); color: var(--accent-red); }
|
|
194
|
+
|
|
195
|
+
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } .sidebar { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); } .stats-grid { grid-template-columns: repeat(4, 1fr); } }
|
|
196
|
+
::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--border); }
|
|
197
|
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
|
198
|
+
@keyframes pulse { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
|
|
199
|
+
@keyframes slideUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
|
200
|
+
.loading-pulse { animation: pulse 1.5s ease-in-out infinite; }
|
|
201
|
+
</style>
|
|
202
|
+
</head>
|
|
203
|
+
<body>
|
|
204
|
+
<header class="header">
|
|
205
|
+
<div class="header-inner">
|
|
206
|
+
<div class="header-left">
|
|
207
|
+
<div class="header-logo">PR</div>
|
|
208
|
+
<div>
|
|
209
|
+
<div class="header-title" id="headerTitle">PR Review Preview</div>
|
|
210
|
+
<div class="header-subtitle" id="headerSubtitle">Loading data...</div>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
214
|
+
<span class="save-status" id="saveStatus"></span>
|
|
215
|
+
<span class="header-badge" id="headerState" style="display:none"></span>
|
|
216
|
+
<span style="font-size:12px;color:var(--text-secondary)" id="headerStats"></span>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</header>
|
|
220
|
+
|
|
221
|
+
<div class="layout">
|
|
222
|
+
<aside class="sidebar">
|
|
223
|
+
<div class="search-wrapper"><input type="text" class="search-input" placeholder="Search comments..." id="searchInput"></div>
|
|
224
|
+
<div class="stats-grid">
|
|
225
|
+
<div class="stat-card stat-critical"><div class="stat-value" id="criticalCount">-</div><div class="stat-label">Critical</div></div>
|
|
226
|
+
<div class="stat-card stat-warning"><div class="stat-value" id="warningCount">-</div><div class="stat-label">Warning</div></div>
|
|
227
|
+
<div class="stat-card stat-suggestion"><div class="stat-value" id="suggestionCount">-</div><div class="stat-label">Suggestion</div></div>
|
|
228
|
+
<div class="stat-card stat-total"><div class="stat-value" id="totalCount">-</div><div class="stat-label">Total</div></div>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="sidebar-section"><div class="sidebar-section-title">Severity</div><div id="severityFilters"></div></div>
|
|
231
|
+
<div class="sidebar-section"><div class="sidebar-section-title">Category <span class="sidebar-action" title="Add category" onclick="openCategoryModal()">+</span></div><div id="categoryFilters"></div></div>
|
|
232
|
+
<div class="sidebar-section"><div class="sidebar-section-title">Files</div><div id="fileFilters" style="max-height:300px;overflow-y:auto"></div></div>
|
|
233
|
+
</aside>
|
|
234
|
+
|
|
235
|
+
<main class="main">
|
|
236
|
+
<div class="main-header">
|
|
237
|
+
<div class="main-title">Review Comments</div>
|
|
238
|
+
<div class="main-controls" id="mainControls" style="display:none">
|
|
239
|
+
<button class="btn" onclick="addNewComment()">+ Add Comment</button>
|
|
240
|
+
<button class="btn" onclick="expandAll()">Expand All</button>
|
|
241
|
+
<button class="btn" onclick="collapseAll()">Collapse All</button>
|
|
242
|
+
<button class="btn btn-primary" onclick="exportJSON()">Export JSON</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="summary-bar" id="summaryBar" style="display:none"><span class="summary-bar-label">Distribution</span><div class="progress-bar" id="progressBar"></div></div>
|
|
246
|
+
<div id="commentsList"></div>
|
|
247
|
+
</main>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<!-- Category Modal (accessible dialog) -->
|
|
251
|
+
<div class="modal-overlay" id="categoryModal" role="dialog" aria-modal="true" aria-labelledby="catModalTitle">
|
|
252
|
+
<div class="modal">
|
|
253
|
+
<div class="modal-header">
|
|
254
|
+
<div class="modal-title" id="catModalTitle">New Category</div>
|
|
255
|
+
<button class="modal-close" onclick="closeCategoryModal()" aria-label="Close">×</button>
|
|
256
|
+
</div>
|
|
257
|
+
<div class="modal-body">
|
|
258
|
+
<div class="modal-field" id="fieldCatKey">
|
|
259
|
+
<label for="catKey">Identifier</label>
|
|
260
|
+
<input class="edit-input" id="catKey" placeholder="e.g. accessibility" autocomplete="off" spellcheck="false">
|
|
261
|
+
<div class="field-hint">Lowercase slug used internally. Auto-generated from label if left empty.</div>
|
|
262
|
+
<div class="field-error">This identifier is already in use.</div>
|
|
263
|
+
</div>
|
|
264
|
+
<div class="modal-field" id="fieldCatLabel">
|
|
265
|
+
<label for="catLabel">Display Name</label>
|
|
266
|
+
<input class="edit-input" id="catLabel" placeholder="e.g. Accessibility">
|
|
267
|
+
<div class="field-error">A display name is required.</div>
|
|
268
|
+
</div>
|
|
269
|
+
<div class="modal-field">
|
|
270
|
+
<label>Color</label>
|
|
271
|
+
<div class="color-picker-row">
|
|
272
|
+
<input type="color" class="color-swatch" id="catColor" value="#58a6ff">
|
|
273
|
+
<input class="edit-input" id="catColorHex" value="#58a6ff" placeholder="#hex" spellcheck="false" style="max-width:120px">
|
|
274
|
+
<span style="flex:1;font-size:12px;color:var(--text-muted)"><span class="color-preview" id="catColorDot" style="background:#58a6ff"></span>Preview</span>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="modal-field">
|
|
278
|
+
<label for="catDesc">Description</label>
|
|
279
|
+
<input class="edit-input" id="catDesc" placeholder="What does this category check?">
|
|
280
|
+
<div class="field-hint">Shown as tooltip when hovering the category filter.</div>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="modal-footer" id="catModalFooterEdit" style="display:none">
|
|
284
|
+
<button class="btn btn-danger" id="catDeleteBtn" onclick="deleteCategoryFromModal()">Delete</button>
|
|
285
|
+
<div style="flex:1"></div>
|
|
286
|
+
<button class="btn" onclick="closeCategoryModal()">Cancel</button>
|
|
287
|
+
<button class="btn btn-primary" id="catUpdateBtn" onclick="saveCategoryFromModal()">Update</button>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="modal-footer" id="catModalFooterCreate">
|
|
290
|
+
<button class="btn" onclick="closeCategoryModal()">Cancel</button>
|
|
291
|
+
<button class="btn btn-primary" id="catSaveBtn" onclick="saveCategoryFromModal()">Create Category</button>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<!-- Delete Category Confirmation -->
|
|
297
|
+
<div class="modal-overlay" id="deleteCatModal" role="dialog" aria-modal="true" aria-labelledby="delCatTitle">
|
|
298
|
+
<div class="modal" style="max-width:440px">
|
|
299
|
+
<div class="modal-header">
|
|
300
|
+
<div class="modal-title" id="delCatTitle">Delete Category</div>
|
|
301
|
+
<button class="modal-close" onclick="closeDeleteCatModal()" aria-label="Close">×</button>
|
|
302
|
+
</div>
|
|
303
|
+
<div class="modal-body">
|
|
304
|
+
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:16px" id="delCatMsg"></p>
|
|
305
|
+
<div class="modal-field" id="delCatReassignField" style="display:none">
|
|
306
|
+
<label for="delCatReassign">Reassign findings to</label>
|
|
307
|
+
<select class="edit-select" id="delCatReassign"></select>
|
|
308
|
+
<div class="field-hint">Findings using this category will be moved to the selected one.</div>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="modal-footer">
|
|
312
|
+
<button class="btn" onclick="closeDeleteCatModal()">Cancel</button>
|
|
313
|
+
<button class="btn btn-danger" onclick="confirmDeleteCategory()">Delete Category</button>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<!-- New Comment Modal -->
|
|
319
|
+
<div class="modal-overlay" id="commentModal" role="dialog" aria-modal="true" aria-labelledby="commentModalTitle">
|
|
320
|
+
<div class="modal" style="max-width:600px">
|
|
321
|
+
<div class="modal-header">
|
|
322
|
+
<div class="modal-title" id="commentModalTitle">New Comment</div>
|
|
323
|
+
<button class="modal-close" onclick="closeCommentModal()" aria-label="Close">×</button>
|
|
324
|
+
</div>
|
|
325
|
+
<div class="modal-body">
|
|
326
|
+
<div class="modal-field" id="fieldComFile">
|
|
327
|
+
<label for="comFile">File path</label>
|
|
328
|
+
<input class="edit-input" id="comFile" placeholder="src/modules/feature/component.tsx" spellcheck="false" style="font-family:var(--font-mono);font-size:12px">
|
|
329
|
+
<div class="field-error">A file path is required.</div>
|
|
330
|
+
</div>
|
|
331
|
+
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px">
|
|
332
|
+
<div class="modal-field">
|
|
333
|
+
<label for="comLine">Line</label>
|
|
334
|
+
<input class="edit-input" id="comLine" type="number" value="1" min="0">
|
|
335
|
+
</div>
|
|
336
|
+
<div class="modal-field">
|
|
337
|
+
<label for="comSeverity">Severity</label>
|
|
338
|
+
<select class="edit-select" id="comSeverity">
|
|
339
|
+
<option value="critical">Critical</option>
|
|
340
|
+
<option value="warning" selected>Warning</option>
|
|
341
|
+
<option value="suggestion">Suggestion</option>
|
|
342
|
+
</select>
|
|
343
|
+
</div>
|
|
344
|
+
<div class="modal-field">
|
|
345
|
+
<label for="comCategory">Category</label>
|
|
346
|
+
<select class="edit-select" id="comCategory"></select>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
<div class="modal-field" id="fieldComTitle">
|
|
350
|
+
<label for="comTitle">Title</label>
|
|
351
|
+
<input class="edit-input" id="comTitle" placeholder="Short, descriptive title for the finding">
|
|
352
|
+
<div class="field-error">A title is required.</div>
|
|
353
|
+
</div>
|
|
354
|
+
<div class="modal-field">
|
|
355
|
+
<label>Body</label>
|
|
356
|
+
<div class="edit-tab-bar">
|
|
357
|
+
<button class="edit-tab active" onclick="switchCommentBodyTab('write',this)">Write</button>
|
|
358
|
+
<button class="edit-tab" onclick="switchCommentBodyTab('preview',this)">Preview</button>
|
|
359
|
+
</div>
|
|
360
|
+
<textarea class="edit-textarea" id="comBody" placeholder="Detailed explanation (supports HTML: <code>, <ul>, <pre>, <strong>)" style="font-family:var(--font-mono);font-size:12px;min-height:120px" oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'"></textarea>
|
|
361
|
+
<div class="edit-preview" id="comBodyPreview" style="display:none"></div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
<div class="modal-footer">
|
|
365
|
+
<button class="btn" onclick="closeCommentModal()">Cancel</button>
|
|
366
|
+
<button class="btn btn-success" onclick="saveNewComment()">Create Comment</button>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<!-- Delete File Findings Confirmation -->
|
|
372
|
+
<div class="modal-overlay" id="deleteFileModal" role="dialog" aria-modal="true" aria-labelledby="delFileTitle">
|
|
373
|
+
<div class="modal" style="max-width:440px">
|
|
374
|
+
<div class="modal-header">
|
|
375
|
+
<div class="modal-title" id="delFileTitle">Remove file findings</div>
|
|
376
|
+
<button class="modal-close" onclick="closeDeleteFileModal()" aria-label="Close">×</button>
|
|
377
|
+
</div>
|
|
378
|
+
<div class="modal-body">
|
|
379
|
+
<p style="font-size:13px;color:var(--text-secondary)" id="delFileMsg"></p>
|
|
380
|
+
<div style="margin-top:12px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border-muted);border-radius:var(--radius);font-family:var(--font-mono);font-size:12px;color:var(--accent-blue);word-break:break-all" id="delFilePath"></div>
|
|
381
|
+
</div>
|
|
382
|
+
<div class="modal-footer">
|
|
383
|
+
<button class="btn" onclick="closeDeleteFileModal()">Cancel</button>
|
|
384
|
+
<button class="btn btn-danger" onclick="confirmDeleteFile()">Remove findings</button>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
|
|
389
|
+
<script>
|
|
390
|
+
// ===== State =====
|
|
391
|
+
let reviewData = [], config = {}, categoryLabels = {}, categoryColors = {};
|
|
392
|
+
let severityIcons = { critical: '\u26d4', warning: '\u26a0\ufe0f', suggestion: '\ud83d\udca1' };
|
|
393
|
+
let activeFilters = { severity: null, category: null, file: null };
|
|
394
|
+
let editingIdx = null, serverAvailable = false;
|
|
395
|
+
let catModalMode = 'create', catModalOriginalKey = null, pendingDeleteCatKey = null, pendingDeleteFile = null;
|
|
396
|
+
const severityOrder = { critical: 0, warning: 1, suggestion: 2 };
|
|
397
|
+
|
|
398
|
+
// ===== Helpers =====
|
|
399
|
+
const getCatLabel = c => categoryLabels[c] || c;
|
|
400
|
+
const getSevIcon = s => severityIcons[s] || '';
|
|
401
|
+
const shortFile = p => { const s = p.split('/'); return s.length > 2 ? '.../' + s.slice(-2).join('/') : p; };
|
|
402
|
+
const escAttr = s => s.replace(/"/g, '"').replace(/'/g, ''');
|
|
403
|
+
|
|
404
|
+
function toast(msg, type = 'success') {
|
|
405
|
+
const t = document.createElement('div');
|
|
406
|
+
t.className = `toast toast-${type}`;
|
|
407
|
+
t.textContent = msg;
|
|
408
|
+
document.body.appendChild(t);
|
|
409
|
+
setTimeout(() => t.remove(), 3000);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function setSaveStatus(connected) {
|
|
413
|
+
const el = document.getElementById('saveStatus');
|
|
414
|
+
if (connected) { el.className = 'save-status save-status-connected'; el.textContent = 'Auto-save'; }
|
|
415
|
+
else { el.className = 'save-status save-status-offline'; el.textContent = 'Read-only'; }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ===== Persist via local server API =====
|
|
419
|
+
async function checkServer() {
|
|
420
|
+
try {
|
|
421
|
+
const ac = new AbortController();
|
|
422
|
+
const timer = setTimeout(() => ac.abort(), 2000);
|
|
423
|
+
const r = await fetch('/api/health', { signal: ac.signal });
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
if (r.ok) { serverAvailable = true; setSaveStatus(true); return; }
|
|
426
|
+
} catch (_) {}
|
|
427
|
+
serverAvailable = false;
|
|
428
|
+
setSaveStatus(false);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Persist both JSONs to disk via the local server API. */
|
|
432
|
+
async function persistToDisk() {
|
|
433
|
+
if (!serverAvailable) {
|
|
434
|
+
// Server wasn't detected at boot — try once more in case it started after page load
|
|
435
|
+
await checkServer();
|
|
436
|
+
if (!serverAvailable) {
|
|
437
|
+
toast('Server not running — run: node .claude/pr-review/serve.js', 'error');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
const [r1, r2] = await Promise.all([
|
|
443
|
+
fetch('/api/save/findings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reviewData, null, 2) }),
|
|
444
|
+
fetch('/api/save/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config, null, 2) }),
|
|
445
|
+
]);
|
|
446
|
+
if (!r1.ok || !r2.ok) { toast('Save failed', 'error'); return; }
|
|
447
|
+
toast('Saved');
|
|
448
|
+
} catch (e) { toast('Connection lost', 'error'); serverAvailable = false; setSaveStatus(false); }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function exportJSON() {
|
|
452
|
+
const b = new Blob([JSON.stringify(reviewData, null, 2)], { type: 'application/json' });
|
|
453
|
+
const u = URL.createObjectURL(b); const a = document.createElement('a');
|
|
454
|
+
a.href = u; a.download = `pr-review-${config.pr?.number || 'export'}.json`; a.click();
|
|
455
|
+
URL.revokeObjectURL(u);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ===== Category Modal (Create / Edit) =====
|
|
459
|
+
function resetCatModal() {
|
|
460
|
+
['fieldCatKey', 'fieldCatLabel'].forEach(id => document.getElementById(id)?.classList.remove('has-error'));
|
|
461
|
+
document.getElementById('catKey').value = '';
|
|
462
|
+
document.getElementById('catKey').removeAttribute('data-manual');
|
|
463
|
+
document.getElementById('catLabel').value = '';
|
|
464
|
+
document.getElementById('catColor').value = '#58a6ff';
|
|
465
|
+
document.getElementById('catColorHex').value = '#58a6ff';
|
|
466
|
+
document.getElementById('catColorDot').style.background = '#58a6ff';
|
|
467
|
+
document.getElementById('catDesc').value = '';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function openCategoryModal() {
|
|
471
|
+
resetCatModal();
|
|
472
|
+
catModalMode = 'create';
|
|
473
|
+
catModalOriginalKey = null;
|
|
474
|
+
document.getElementById('catModalTitle').textContent = 'New Category';
|
|
475
|
+
document.getElementById('catKey').removeAttribute('disabled');
|
|
476
|
+
document.getElementById('fieldCatKey').querySelector('.field-error').textContent = 'This identifier is already in use.';
|
|
477
|
+
document.getElementById('catModalFooterCreate').style.display = 'flex';
|
|
478
|
+
document.getElementById('catModalFooterEdit').style.display = 'none';
|
|
479
|
+
document.getElementById('categoryModal').classList.add('open');
|
|
480
|
+
setTimeout(() => document.getElementById('catLabel').focus(), 100);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function openEditCategoryModal(key) {
|
|
484
|
+
resetCatModal();
|
|
485
|
+
catModalMode = 'edit';
|
|
486
|
+
catModalOriginalKey = key;
|
|
487
|
+
const cat = config.categories?.[key] || {};
|
|
488
|
+
document.getElementById('catModalTitle').textContent = 'Edit Category';
|
|
489
|
+
document.getElementById('catKey').value = key;
|
|
490
|
+
document.getElementById('catKey').setAttribute('disabled', '');
|
|
491
|
+
document.getElementById('catKey').dataset.manual = '1';
|
|
492
|
+
document.getElementById('catLabel').value = cat.label || key;
|
|
493
|
+
const color = cat.color || categoryColors[key] || '#58a6ff';
|
|
494
|
+
document.getElementById('catColor').value = color;
|
|
495
|
+
document.getElementById('catColorHex').value = color;
|
|
496
|
+
document.getElementById('catColorDot').style.background = color;
|
|
497
|
+
document.getElementById('catDesc').value = cat.description || '';
|
|
498
|
+
document.getElementById('catModalFooterCreate').style.display = 'none';
|
|
499
|
+
document.getElementById('catModalFooterEdit').style.display = 'flex';
|
|
500
|
+
document.getElementById('categoryModal').classList.add('open');
|
|
501
|
+
setTimeout(() => document.getElementById('catLabel').focus(), 100);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function closeCategoryModal() {
|
|
505
|
+
document.getElementById('categoryModal').classList.remove('open');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function saveCategoryFromModal() {
|
|
509
|
+
const label = document.getElementById('catLabel').value.trim();
|
|
510
|
+
let key;
|
|
511
|
+
|
|
512
|
+
if (catModalMode === 'edit') {
|
|
513
|
+
key = catModalOriginalKey;
|
|
514
|
+
} else {
|
|
515
|
+
key = document.getElementById('catKey').value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
516
|
+
if (!key && label) key = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Validate
|
|
520
|
+
let valid = true;
|
|
521
|
+
document.getElementById('fieldCatLabel').classList.remove('has-error');
|
|
522
|
+
document.getElementById('fieldCatKey').classList.remove('has-error');
|
|
523
|
+
if (!label) { document.getElementById('fieldCatLabel').classList.add('has-error'); valid = false; }
|
|
524
|
+
if (catModalMode === 'create' && key && categoryLabels[key]) { document.getElementById('fieldCatKey').classList.add('has-error'); valid = false; }
|
|
525
|
+
if (!valid) return;
|
|
526
|
+
if (!key) key = 'category-' + Date.now();
|
|
527
|
+
|
|
528
|
+
const color = document.getElementById('catColorHex').value.trim() || '#58a6ff';
|
|
529
|
+
const desc = document.getElementById('catDesc').value.trim();
|
|
530
|
+
|
|
531
|
+
categoryLabels[key] = label;
|
|
532
|
+
categoryColors[key] = color;
|
|
533
|
+
config.categories = config.categories || {};
|
|
534
|
+
config.categories[key] = { label, color, description: desc };
|
|
535
|
+
injectCategoryCSS();
|
|
536
|
+
closeCategoryModal();
|
|
537
|
+
render();
|
|
538
|
+
await persistToDisk();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ===== Delete Category =====
|
|
542
|
+
function openDeleteCategoryModal(key) {
|
|
543
|
+
pendingDeleteCatKey = key;
|
|
544
|
+
const count = reviewData.filter(i => i.category === key).length;
|
|
545
|
+
const label = getCatLabel(key);
|
|
546
|
+
|
|
547
|
+
document.getElementById('delCatTitle').textContent = `Delete "${label}"`;
|
|
548
|
+
|
|
549
|
+
if (count === 0) {
|
|
550
|
+
document.getElementById('delCatMsg').innerHTML = `The category <strong>${label}</strong> has no findings. It will be removed from the configuration.`;
|
|
551
|
+
document.getElementById('delCatReassignField').style.display = 'none';
|
|
552
|
+
} else {
|
|
553
|
+
document.getElementById('delCatMsg').innerHTML = `The category <strong>${label}</strong> is used by <strong>${count} finding${count > 1 ? 's' : ''}</strong>. Choose a category to reassign them to before deleting.`;
|
|
554
|
+
document.getElementById('delCatReassignField').style.display = 'block';
|
|
555
|
+
const otherCats = Object.keys(categoryLabels).filter(k => k !== key);
|
|
556
|
+
document.getElementById('delCatReassign').innerHTML = otherCats.map(k =>
|
|
557
|
+
`<option value="${k}">${getCatLabel(k)}</option>`
|
|
558
|
+
).join('');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
closeCategoryModal(); // close edit modal if open
|
|
562
|
+
document.getElementById('deleteCatModal').classList.add('open');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function closeDeleteCatModal() {
|
|
566
|
+
document.getElementById('deleteCatModal').classList.remove('open');
|
|
567
|
+
pendingDeleteCatKey = null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function confirmDeleteCategory() {
|
|
571
|
+
const key = pendingDeleteCatKey;
|
|
572
|
+
if (!key) return;
|
|
573
|
+
|
|
574
|
+
const count = reviewData.filter(i => i.category === key).length;
|
|
575
|
+
|
|
576
|
+
// Reassign findings if needed
|
|
577
|
+
if (count > 0) {
|
|
578
|
+
const newCat = document.getElementById('delCatReassign').value;
|
|
579
|
+
if (!newCat) { toast('Select a category to reassign to', 'error'); return; }
|
|
580
|
+
reviewData.forEach(item => { if (item.category === key) item.category = newCat; });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Remove from state
|
|
584
|
+
delete categoryLabels[key];
|
|
585
|
+
delete categoryColors[key];
|
|
586
|
+
if (config.categories) delete config.categories[key];
|
|
587
|
+
|
|
588
|
+
// Clear filter if active
|
|
589
|
+
if (activeFilters.category === key) activeFilters.category = null;
|
|
590
|
+
|
|
591
|
+
injectCategoryCSS();
|
|
592
|
+
closeDeleteCatModal();
|
|
593
|
+
render();
|
|
594
|
+
await persistToDisk();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Also allow delete from edit modal
|
|
598
|
+
function deleteCategoryFromModal() {
|
|
599
|
+
const key = catModalOriginalKey;
|
|
600
|
+
if (!key) return;
|
|
601
|
+
closeCategoryModal();
|
|
602
|
+
openDeleteCategoryModal(key);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ===== Delete File Findings =====
|
|
606
|
+
function openDeleteFileModal(file) {
|
|
607
|
+
pendingDeleteFile = file;
|
|
608
|
+
const count = reviewData.filter(i => i.file === file).length;
|
|
609
|
+
const display = file || '(no file)';
|
|
610
|
+
document.getElementById('delFileTitle').textContent = 'Remove file findings';
|
|
611
|
+
document.getElementById('delFileMsg').innerHTML = `This will remove <strong>${count} finding${count !== 1 ? 's' : ''}</strong> associated with this file. This action cannot be undone.`;
|
|
612
|
+
document.getElementById('delFilePath').textContent = display;
|
|
613
|
+
document.getElementById('deleteFileModal').classList.add('open');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function closeDeleteFileModal() {
|
|
617
|
+
document.getElementById('deleteFileModal').classList.remove('open');
|
|
618
|
+
pendingDeleteFile = null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function confirmDeleteFile() {
|
|
622
|
+
if (pendingDeleteFile === null) return;
|
|
623
|
+
reviewData = reviewData.filter(i => i.file !== pendingDeleteFile);
|
|
624
|
+
if (activeFilters.file === pendingDeleteFile) activeFilters.file = null;
|
|
625
|
+
closeDeleteFileModal();
|
|
626
|
+
if (reviewData.length === 0) showEmptyState('all-clear');
|
|
627
|
+
else render();
|
|
628
|
+
await persistToDisk();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Sync color picker <-> hex input <-> preview dot
|
|
632
|
+
document.getElementById('catColor').addEventListener('input', e => {
|
|
633
|
+
document.getElementById('catColorHex').value = e.target.value;
|
|
634
|
+
document.getElementById('catColorDot').style.background = e.target.value;
|
|
635
|
+
});
|
|
636
|
+
document.getElementById('catColorHex').addEventListener('input', e => {
|
|
637
|
+
const v = e.target.value;
|
|
638
|
+
if (/^#[0-9a-f]{6}$/i.test(v)) { document.getElementById('catColor').value = v; document.getElementById('catColorDot').style.background = v; }
|
|
639
|
+
});
|
|
640
|
+
// Auto-generate key from label as user types (only in create mode)
|
|
641
|
+
document.getElementById('catLabel').addEventListener('input', e => {
|
|
642
|
+
const keyEl = document.getElementById('catKey');
|
|
643
|
+
if (!keyEl.dataset.manual && catModalMode === 'create') keyEl.value = e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
644
|
+
});
|
|
645
|
+
document.getElementById('catKey').addEventListener('input', () => { document.getElementById('catKey').dataset.manual = '1'; });
|
|
646
|
+
|
|
647
|
+
// ===== New Comment Modal =====
|
|
648
|
+
function addNewComment() {
|
|
649
|
+
// Reset fields
|
|
650
|
+
['fieldComFile', 'fieldComTitle'].forEach(id => document.getElementById(id)?.classList.remove('has-error'));
|
|
651
|
+
document.getElementById('comFile').value = '';
|
|
652
|
+
document.getElementById('comLine').value = '1';
|
|
653
|
+
document.getElementById('comSeverity').value = 'warning';
|
|
654
|
+
document.getElementById('comTitle').value = '';
|
|
655
|
+
document.getElementById('comBody').value = '';
|
|
656
|
+
document.getElementById('comBody').style.display = 'block';
|
|
657
|
+
document.getElementById('comBodyPreview').style.display = 'none';
|
|
658
|
+
// Reset tabs to Write
|
|
659
|
+
document.querySelectorAll('#commentModal .edit-tab').forEach((t,i) => t.classList.toggle('active', i === 0));
|
|
660
|
+
// Populate category select
|
|
661
|
+
document.getElementById('comCategory').innerHTML = Object.keys(categoryLabels).map(k =>
|
|
662
|
+
`<option value="${k}">${getCatLabel(k)}</option>`
|
|
663
|
+
).join('');
|
|
664
|
+
document.getElementById('commentModal').classList.add('open');
|
|
665
|
+
setTimeout(() => document.getElementById('comFile').focus(), 100);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function closeCommentModal() {
|
|
669
|
+
document.getElementById('commentModal').classList.remove('open');
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function saveNewComment() {
|
|
673
|
+
const file = document.getElementById('comFile').value.trim();
|
|
674
|
+
const title = document.getElementById('comTitle').value.trim();
|
|
675
|
+
// Validate
|
|
676
|
+
let valid = true;
|
|
677
|
+
document.getElementById('fieldComFile').classList.remove('has-error');
|
|
678
|
+
document.getElementById('fieldComTitle').classList.remove('has-error');
|
|
679
|
+
if (!file) { document.getElementById('fieldComFile').classList.add('has-error'); valid = false; }
|
|
680
|
+
if (!title) { document.getElementById('fieldComTitle').classList.add('has-error'); valid = false; }
|
|
681
|
+
if (!valid) return;
|
|
682
|
+
|
|
683
|
+
reviewData.unshift({
|
|
684
|
+
file,
|
|
685
|
+
line: parseInt(document.getElementById('comLine').value) || 0,
|
|
686
|
+
severity: document.getElementById('comSeverity').value,
|
|
687
|
+
category: document.getElementById('comCategory').value,
|
|
688
|
+
title,
|
|
689
|
+
body: document.getElementById('comBody').value
|
|
690
|
+
});
|
|
691
|
+
closeCommentModal();
|
|
692
|
+
activeFilters = { severity: null, category: null, file: null };
|
|
693
|
+
render();
|
|
694
|
+
await persistToDisk();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function switchCommentBodyTab(tab, btnEl) {
|
|
698
|
+
const textarea = document.getElementById('comBody');
|
|
699
|
+
const preview = document.getElementById('comBodyPreview');
|
|
700
|
+
btnEl.parentElement.querySelectorAll('.edit-tab').forEach(t => t.classList.remove('active'));
|
|
701
|
+
btnEl.classList.add('active');
|
|
702
|
+
if (tab === 'preview') {
|
|
703
|
+
preview.innerHTML = textarea.value || '';
|
|
704
|
+
textarea.style.display = 'none';
|
|
705
|
+
preview.style.display = 'block';
|
|
706
|
+
} else {
|
|
707
|
+
textarea.style.display = 'block';
|
|
708
|
+
preview.style.display = 'none';
|
|
709
|
+
textarea.focus();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function startEdit(idx) { editingIdx = idx; render(); }
|
|
714
|
+
function cancelEdit() { editingIdx = null; render(); }
|
|
715
|
+
|
|
716
|
+
async function saveEdit(idx) {
|
|
717
|
+
const form = document.getElementById(`edit-form-${idx}`);
|
|
718
|
+
if (!form) return;
|
|
719
|
+
reviewData[idx].file = form.querySelector('[data-field="file"]').value.trim();
|
|
720
|
+
reviewData[idx].title = form.querySelector('[data-field="title"]').value.trim();
|
|
721
|
+
reviewData[idx].body = form.querySelector('[data-field="body"]').value;
|
|
722
|
+
reviewData[idx].severity = form.querySelector('[data-field="severity"]').value;
|
|
723
|
+
reviewData[idx].category = form.querySelector('[data-field="category"]').value;
|
|
724
|
+
reviewData[idx].line = parseInt(form.querySelector('[data-field="line"]').value) || 0;
|
|
725
|
+
editingIdx = null;
|
|
726
|
+
render();
|
|
727
|
+
await persistToDisk();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function deleteComment(idx) {
|
|
731
|
+
reviewData.splice(idx, 1);
|
|
732
|
+
editingIdx = null;
|
|
733
|
+
if (reviewData.length === 0) showEmptyState('all-clear');
|
|
734
|
+
else render();
|
|
735
|
+
await persistToDisk();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ===== Empty States =====
|
|
739
|
+
function showEmptyState(type) {
|
|
740
|
+
const el = document.getElementById('commentsList');
|
|
741
|
+
const states = {
|
|
742
|
+
loading: { icon: '\u23f3', title: 'Loading review data...', text: 'Fetching findings.json and config.json', hint: null, cls: 'loading-pulse' },
|
|
743
|
+
'no-analysis': { icon: '\ud83d\udcdd', title: 'No review analysis found', text: 'No <code>findings.json</code> was found. Run the PR review agent to generate the analysis, or create the file manually.', hint: '/pr-review:review <pr-url>' },
|
|
744
|
+
'all-clear': { icon: '\u2705', title: 'All clear!', text: 'The analysis found <strong>0 issues</strong>. This PR looks good against the configured review criteria.', hint: null },
|
|
745
|
+
'no-match': { icon: '\ud83d\udd0d', title: 'No results', text: 'No comments match the current filters. Try adjusting the search or clearing a filter.', hint: null },
|
|
746
|
+
'fetch-error': { icon: '\u26a0\ufe0f', title: 'Could not load data', text: 'Failed to fetch JSON files. If using <code>file://</code>, you need a local server.', hint: 'node .claude/pr-review/serve.js' }
|
|
747
|
+
};
|
|
748
|
+
const s = states[type] || states['no-analysis'];
|
|
749
|
+
el.innerHTML = `<div class="empty-state"><div class="empty-state-icon ${s.cls||''}">${s.icon}</div><div class="empty-state-title">${s.title}</div><div class="empty-state-text">${s.text}</div>${s.hint ? `<div class="empty-state-hint">${s.hint}</div>` : ''}</div>`;
|
|
750
|
+
['criticalCount','warningCount','suggestionCount','totalCount'].forEach(id => { document.getElementById(id).textContent = type === 'loading' ? '-' : '0'; });
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ===== Header & CSS =====
|
|
754
|
+
function renderHeader() {
|
|
755
|
+
const pr = config.pr;
|
|
756
|
+
if (!pr) { document.getElementById('headerTitle').textContent = 'PR Review Preview'; document.getElementById('headerSubtitle').textContent = 'No config.json loaded'; return; }
|
|
757
|
+
document.title = `PR Review \u2014 ${pr.repo || ''}#${pr.number || '?'}`;
|
|
758
|
+
document.getElementById('headerTitle').innerHTML = `${pr.title || 'Untitled'} <span style="color:var(--text-muted);font-weight:400">#${pr.number || '?'}</span>`;
|
|
759
|
+
document.getElementById('headerSubtitle').innerHTML = `${pr.repo || ''} · ${pr.head || '?'} → ${pr.base || '?'}`;
|
|
760
|
+
const badge = document.getElementById('headerState'); badge.textContent = (pr.state || '')[0]?.toUpperCase() + (pr.state || '').slice(1); badge.style.display = 'inline-flex';
|
|
761
|
+
document.getElementById('headerStats').textContent = `${pr.changedFiles || 0} files \u00b7 +${(pr.additions||0).toLocaleString()} / -${(pr.deletions||0).toLocaleString()}`;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function injectCategoryCSS() {
|
|
765
|
+
let el = document.getElementById('dynamic-cat-css');
|
|
766
|
+
if (!el) { el = document.createElement('style'); el.id = 'dynamic-cat-css'; document.head.appendChild(el); }
|
|
767
|
+
el.textContent = Object.entries(categoryColors).map(([k,v]) => `.cat-${k}{background:${v}}`).join('\n');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ===== Render =====
|
|
771
|
+
function render() {
|
|
772
|
+
if (reviewData.length === 0) return;
|
|
773
|
+
|
|
774
|
+
const search = document.getElementById('searchInput').value.toLowerCase();
|
|
775
|
+
const filtered = reviewData.map((item, i) => ({ ...item, _idx: i })).filter(item => {
|
|
776
|
+
if (activeFilters.severity && item.severity !== activeFilters.severity) return false;
|
|
777
|
+
if (activeFilters.category && item.category !== activeFilters.category) return false;
|
|
778
|
+
if (activeFilters.file && item.file !== activeFilters.file) return false;
|
|
779
|
+
if (search && !(item.title + item.body + item.file + item.category).toLowerCase().includes(search)) return false;
|
|
780
|
+
return true;
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const total = filtered.length, critical = filtered.filter(i => i.severity === 'critical').length;
|
|
784
|
+
const warning = filtered.filter(i => i.severity === 'warning').length, suggestion = filtered.filter(i => i.severity === 'suggestion').length;
|
|
785
|
+
document.getElementById('criticalCount').textContent = critical;
|
|
786
|
+
document.getElementById('warningCount').textContent = warning;
|
|
787
|
+
document.getElementById('suggestionCount').textContent = suggestion;
|
|
788
|
+
document.getElementById('totalCount').textContent = total;
|
|
789
|
+
|
|
790
|
+
document.getElementById('summaryBar').style.display = total > 0 ? 'flex' : 'none';
|
|
791
|
+
if (total > 0) document.getElementById('progressBar').innerHTML = `<div class="progress-segment" style="width:${critical/total*100}%;background:var(--accent-red)"></div><div class="progress-segment" style="width:${warning/total*100}%;background:var(--accent-yellow)"></div><div class="progress-segment" style="width:${suggestion/total*100}%;background:var(--accent-blue)"></div>`;
|
|
792
|
+
|
|
793
|
+
// Sidebar
|
|
794
|
+
document.getElementById('severityFilters').innerHTML = ['critical','warning','suggestion'].map(sev => {
|
|
795
|
+
const c = reviewData.filter(i => i.severity === sev).length, a = activeFilters.severity === sev ? ' active' : '';
|
|
796
|
+
return `<div class="filter-item${a}" onclick="toggleFilter('severity','${sev}')"><span class="filter-item-left"><span class="severity-badge severity-${sev}" style="font-size:10px">${sev}</span></span><span class="filter-count">${c}</span></div>`;
|
|
797
|
+
}).join('');
|
|
798
|
+
|
|
799
|
+
// Merge categories from findings + config so new empty categories are visible
|
|
800
|
+
const catsFromData = new Set(reviewData.map(i => i.category));
|
|
801
|
+
const catsFromConfig = new Set(Object.keys(categoryLabels));
|
|
802
|
+
const allCats = [...new Set([...catsFromData, ...catsFromConfig])];
|
|
803
|
+
document.getElementById('categoryFilters').innerHTML = allCats.map(cat => {
|
|
804
|
+
const c = reviewData.filter(i => i.category === cat).length, a = activeFilters.category === cat ? ' active' : '';
|
|
805
|
+
return `<div class="filter-item${a}">
|
|
806
|
+
<span class="filter-item-left" onclick="toggleFilter('category','${cat}')"><span class="filter-dot cat-${cat}"></span> ${getCatLabel(cat)}</span>
|
|
807
|
+
<span class="filter-right">
|
|
808
|
+
<span class="filter-count" onclick="toggleFilter('category','${cat}')">${c}</span>
|
|
809
|
+
<button class="filter-action" onclick="event.stopPropagation();openEditCategoryModal('${cat}')" title="Edit">\u270e</button>
|
|
810
|
+
<button class="filter-action danger" onclick="event.stopPropagation();openDeleteCategoryModal('${cat}')" title="Delete">\u2715</button>
|
|
811
|
+
</span>
|
|
812
|
+
</div>`;
|
|
813
|
+
}).join('');
|
|
814
|
+
|
|
815
|
+
const files = [...new Set(reviewData.map(i => i.file))];
|
|
816
|
+
document.getElementById('fileFilters').innerHTML = files.map(f => {
|
|
817
|
+
const c = reviewData.filter(i => i.file === f).length, a = activeFilters.file === f ? ' active' : '';
|
|
818
|
+
const escaped = f.replace(/'/g, "\\'");
|
|
819
|
+
return `<div class="filter-item${a}">
|
|
820
|
+
<span class="filter-item-left" style="min-width:0" onclick="toggleFilter('file','${escaped}')"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-family:var(--font-mono);${f ? '' : 'font-style:italic;color:var(--text-muted)'}">${f ? shortFile(f) : '(no file)'}</span></span>
|
|
821
|
+
<span class="filter-right">
|
|
822
|
+
<span class="filter-count" onclick="toggleFilter('file','${escaped}')">${c}</span>
|
|
823
|
+
<button class="filter-action danger" onclick="event.stopPropagation();openDeleteFileModal('${escaped}')" title="Remove all findings for this file">\u2715</button>
|
|
824
|
+
</span>
|
|
825
|
+
</div>`;
|
|
826
|
+
}).join('');
|
|
827
|
+
|
|
828
|
+
// Comments
|
|
829
|
+
const el = document.getElementById('commentsList');
|
|
830
|
+
if (filtered.length === 0) { showEmptyState('no-match'); return; }
|
|
831
|
+
|
|
832
|
+
// Group — but preserve _idx for editing
|
|
833
|
+
const groups = {};
|
|
834
|
+
filtered.forEach(item => { (groups[item.file] ||= []).push(item); });
|
|
835
|
+
const sortedGroups = Object.entries(groups).sort(([,a],[,b]) => Math.min(...a.map(i => severityOrder[i.severity])) - Math.min(...b.map(i => severityOrder[i.severity])));
|
|
836
|
+
|
|
837
|
+
el.innerHTML = sortedGroups.map(([file, items]) => {
|
|
838
|
+
const sorted = items.sort((a,b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
839
|
+
return `<div class="comment-group">
|
|
840
|
+
<div class="comment-group-header" onclick="this.classList.toggle('collapsed')">
|
|
841
|
+
<span class="collapse-icon">\u25bc</span><span class="file-icon">\ud83d\udcc4</span>
|
|
842
|
+
<span class="file-path" ${file ? '' : 'style="font-style:italic;color:var(--text-muted)"'}>${file ? shortFile(file) : '(new comment)'}</span><span class="file-badge-count">${items.length}</span>
|
|
843
|
+
</div>
|
|
844
|
+
<div class="comment-list">${sorted.map(i => editingIdx === i._idx ? renderEditForm(i) : renderCard(i)).join('')}</div>
|
|
845
|
+
</div>`;
|
|
846
|
+
}).join('');
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function renderCard(i) {
|
|
850
|
+
return `<div class="comment-card">
|
|
851
|
+
<div class="card-actions">
|
|
852
|
+
<button class="card-action-btn" onclick="event.stopPropagation();startEdit(${i._idx})" title="Edit">\u270e</button>
|
|
853
|
+
<button class="card-action-btn danger" onclick="event.stopPropagation();if(confirm('Delete this comment?'))deleteComment(${i._idx})" title="Delete">\u2715</button>
|
|
854
|
+
</div>
|
|
855
|
+
<div class="comment-header">
|
|
856
|
+
<span class="severity-badge severity-${i.severity}">${getSevIcon(i.severity)} ${i.severity}</span>
|
|
857
|
+
<span class="category-tag"><span class="filter-dot cat-${i.category}"></span>${getCatLabel(i.category)}</span>
|
|
858
|
+
<span class="comment-title">${i.title}</span>
|
|
859
|
+
${i.line ? `<span class="line-ref">L${i.line}</span>` : ''}
|
|
860
|
+
</div>
|
|
861
|
+
<div class="comment-body">${i.body}</div>
|
|
862
|
+
</div>`;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function renderEditForm(i) {
|
|
866
|
+
const id = i._idx;
|
|
867
|
+
const bodyEscaped = i.body.replace(/</g,'<').replace(/>/g,'>');
|
|
868
|
+
return `<div class="comment-card editing">
|
|
869
|
+
<div class="edit-form" id="edit-form-${id}">
|
|
870
|
+
<div class="edit-form-header">
|
|
871
|
+
<span class="severity-badge severity-${i.severity}" style="font-size:10px">${getSevIcon(i.severity)} ${i.severity}</span>
|
|
872
|
+
<span class="edit-form-header-title">${i.title || 'New comment'}</span>
|
|
873
|
+
<span class="line-ref">#${id + 1}</span>
|
|
874
|
+
</div>
|
|
875
|
+
<div class="edit-form-body">
|
|
876
|
+
<div class="edit-section">
|
|
877
|
+
<div class="edit-section-label">Location</div>
|
|
878
|
+
<div class="edit-grid">
|
|
879
|
+
<div class="edit-field full"><label>File path</label><input class="edit-input" data-field="file" value="${escAttr(i.file)}" placeholder="src/modules/feature/component.tsx" spellcheck="false" style="font-family:var(--font-mono);font-size:12px"></div>
|
|
880
|
+
<div class="edit-field"><label>Line</label><input class="edit-input" data-field="line" type="number" value="${i.line||0}" min="0"></div>
|
|
881
|
+
<div class="edit-field"><label> </label></div>
|
|
882
|
+
</div>
|
|
883
|
+
</div>
|
|
884
|
+
<div class="edit-section">
|
|
885
|
+
<div class="edit-section-label">Classification</div>
|
|
886
|
+
<div class="edit-grid">
|
|
887
|
+
<div class="edit-field"><label>Severity</label><select class="edit-select" data-field="severity" onchange="updateEditHeader(${id},this)"><option value="critical" ${i.severity==='critical'?'selected':''}>Critical</option><option value="warning" ${i.severity==='warning'?'selected':''}>Warning</option><option value="suggestion" ${i.severity==='suggestion'?'selected':''}>Suggestion</option></select></div>
|
|
888
|
+
<div class="edit-field"><label>Category</label><select class="edit-select" data-field="category">${Object.keys(categoryLabels).map(k=>`<option value="${k}" ${i.category===k?'selected':''}>${getCatLabel(k)}</option>`).join('')}</select></div>
|
|
889
|
+
</div>
|
|
890
|
+
</div>
|
|
891
|
+
<div class="edit-section">
|
|
892
|
+
<div class="edit-section-label">Content</div>
|
|
893
|
+
<div class="edit-field"><label>Title</label><input class="edit-input" data-field="title" value="${escAttr(i.title)}" placeholder="Short, descriptive title for the finding" oninput="updateEditHeader(${id})"></div>
|
|
894
|
+
<div class="edit-field">
|
|
895
|
+
<div class="edit-tab-bar">
|
|
896
|
+
<button class="edit-tab active" onclick="switchBodyTab(${id},'write',this)">Write</button>
|
|
897
|
+
<button class="edit-tab" onclick="switchBodyTab(${id},'preview',this)">Preview</button>
|
|
898
|
+
</div>
|
|
899
|
+
<textarea class="edit-textarea" data-field="body" id="edit-body-${id}" placeholder="Detailed explanation (supports HTML: <code>, <ul>, <pre>, <strong>)" oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'">${bodyEscaped}</textarea>
|
|
900
|
+
<div class="edit-preview" id="edit-preview-${id}" style="display:none"></div>
|
|
901
|
+
</div>
|
|
902
|
+
</div>
|
|
903
|
+
</div>
|
|
904
|
+
<div class="edit-form-footer">
|
|
905
|
+
<button class="btn btn-danger" onclick="if(confirm('Delete this comment?'))deleteComment(${id})">Delete</button>
|
|
906
|
+
<button class="btn" onclick="cancelEdit()">Cancel</button>
|
|
907
|
+
<button class="btn btn-success" onclick="saveEdit(${id})">Save Changes</button>
|
|
908
|
+
</div>
|
|
909
|
+
</div>
|
|
910
|
+
</div>`;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function updateEditHeader(idx, selectEl) {
|
|
914
|
+
const form = document.getElementById(`edit-form-${idx}`);
|
|
915
|
+
if (!form) return;
|
|
916
|
+
const header = form.querySelector('.edit-form-header');
|
|
917
|
+
const titleInput = form.querySelector('[data-field="title"]');
|
|
918
|
+
const sevSelect = selectEl || form.querySelector('[data-field="severity"]');
|
|
919
|
+
const sev = sevSelect.value;
|
|
920
|
+
const badge = header.querySelector('.severity-badge');
|
|
921
|
+
badge.className = `severity-badge severity-${sev}`;
|
|
922
|
+
badge.style.fontSize = '10px';
|
|
923
|
+
badge.innerHTML = `${getSevIcon(sev)} ${sev}`;
|
|
924
|
+
header.querySelector('.edit-form-header-title').textContent = titleInput.value || 'New comment';
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function switchBodyTab(idx, tab, btnEl) {
|
|
928
|
+
const textarea = document.getElementById(`edit-body-${idx}`);
|
|
929
|
+
const preview = document.getElementById(`edit-preview-${idx}`);
|
|
930
|
+
const tabs = btnEl.parentElement.querySelectorAll('.edit-tab');
|
|
931
|
+
tabs.forEach(t => t.classList.remove('active'));
|
|
932
|
+
btnEl.classList.add('active');
|
|
933
|
+
if (tab === 'preview') {
|
|
934
|
+
preview.innerHTML = textarea.value || '';
|
|
935
|
+
textarea.style.display = 'none';
|
|
936
|
+
preview.style.display = 'block';
|
|
937
|
+
} else {
|
|
938
|
+
textarea.style.display = 'block';
|
|
939
|
+
preview.style.display = 'none';
|
|
940
|
+
textarea.focus();
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// ===== Interactions =====
|
|
945
|
+
function toggleFilter(type, value) { activeFilters[type] = activeFilters[type] === value ? null : value; render(); }
|
|
946
|
+
function expandAll() { document.querySelectorAll('.comment-group-header.collapsed').forEach(h => h.classList.remove('collapsed')); }
|
|
947
|
+
function collapseAll() { document.querySelectorAll('.comment-group-header:not(.collapsed)').forEach(h => h.classList.add('collapsed')); }
|
|
948
|
+
|
|
949
|
+
// Close modals on overlay click
|
|
950
|
+
const modals = {
|
|
951
|
+
categoryModal: closeCategoryModal,
|
|
952
|
+
deleteCatModal: closeDeleteCatModal,
|
|
953
|
+
commentModal: closeCommentModal,
|
|
954
|
+
deleteFileModal: closeDeleteFileModal
|
|
955
|
+
};
|
|
956
|
+
Object.entries(modals).forEach(([id, closeFn]) => {
|
|
957
|
+
document.getElementById(id).addEventListener('click', e => { if (e.target === e.currentTarget) closeFn(); });
|
|
958
|
+
});
|
|
959
|
+
document.addEventListener('keydown', e => {
|
|
960
|
+
if (e.key === 'Escape') {
|
|
961
|
+
for (const [id, closeFn] of Object.entries(modals)) {
|
|
962
|
+
if (document.getElementById(id).classList.contains('open')) { closeFn(); return; }
|
|
963
|
+
}
|
|
964
|
+
if (editingIdx !== null) cancelEdit();
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// ===== Bootstrap =====
|
|
969
|
+
async function loadData() {
|
|
970
|
+
showEmptyState('loading');
|
|
971
|
+
|
|
972
|
+
// Check if local server is running (enables save-to-disk)
|
|
973
|
+
await checkServer();
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
const r = await fetch('./config.json');
|
|
977
|
+
if (r.ok) {
|
|
978
|
+
config = await r.json();
|
|
979
|
+
if (config.categories) Object.entries(config.categories).forEach(([k,v]) => { categoryLabels[k] = v.label || k; categoryColors[k] = v.color || '#8b949e'; });
|
|
980
|
+
if (config.severities) Object.entries(config.severities).forEach(([k,v]) => { severityIcons[k] = v.icon || severityIcons[k]; });
|
|
981
|
+
}
|
|
982
|
+
} catch (_) {}
|
|
983
|
+
|
|
984
|
+
renderHeader();
|
|
985
|
+
injectCategoryCSS();
|
|
986
|
+
|
|
987
|
+
let findingsLoaded = false;
|
|
988
|
+
try {
|
|
989
|
+
const r = await fetch('./findings.json');
|
|
990
|
+
if (r.ok) { const d = await r.json(); if (Array.isArray(d)) { reviewData = d; findingsLoaded = true; } }
|
|
991
|
+
} catch (e) { if (location.protocol === 'file:') { showEmptyState('fetch-error'); return; } }
|
|
992
|
+
|
|
993
|
+
document.getElementById('mainControls').style.display = 'flex';
|
|
994
|
+
if (!findingsLoaded) showEmptyState('no-analysis');
|
|
995
|
+
else if (reviewData.length === 0) showEmptyState('all-clear');
|
|
996
|
+
else render();
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
document.getElementById('searchInput').addEventListener('input', render);
|
|
1000
|
+
loadData();
|
|
1001
|
+
</script>
|
|
1002
|
+
</body>
|
|
1003
|
+
</html>
|