reviewzy 0.1.0 → 0.2.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/.env.example +5 -0
- package/README.md +29 -10
- package/package.json +1 -1
- package/src/config.ts +4 -0
- package/src/dashboard/mount.ts +118 -1
- package/src/dashboard/repo-link.ts +126 -0
- package/src/dashboard/static/components.css +15 -3
- package/src/dashboard/static/dashboard.css +172 -37
- package/src/dashboard/static/dashboard.js +141 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/views.tsx +503 -142
- package/src/dashboard/word-diff.ts +89 -0
- package/src/db/human-save.ts +112 -0
- package/src/db/migrations.ts +38 -0
- package/src/db/queries.ts +35 -12
- package/src/db/sweep.ts +2 -2
- package/src/mcp/file-entries.ts +53 -1
- package/src/mcp/list-entries.ts +41 -3
- package/src/mcp/mark-applied.ts +1 -1
- package/src/mcp/route.ts +6 -9
- package/src/mcp/server.ts +1 -1
- package/src/shim/daemon.ts +23 -16
- package/src/shim/main.ts +26 -5
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
|
|
7
7
|
.main { padding: var(--space-10) var(--space-12); max-width: 1400px; margin: 0 auto; width: 100%; }
|
|
8
8
|
|
|
9
|
-
/* ---- Page header: the eyebrow / title / lede triplet ---- */
|
|
9
|
+
/* ---- Page header: the eyebrow / title / lede triplet (login/editor pages) ---- */
|
|
10
10
|
.page-header { margin-bottom: var(--space-8); padding-bottom: var(--space-5); border-bottom: 1px solid var(--line); }
|
|
11
11
|
.page-eyebrow { color: var(--accent); margin-bottom: var(--space-2); }
|
|
12
12
|
.page-title { font: 550 var(--text-2xl)/1.15 var(--font-sans); color: var(--text); letter-spacing: -0.02em; margin-bottom: var(--space-3); }
|
|
13
13
|
.page-lede { font-size: var(--text-base); color: var(--text-dim); max-width: 540px; }
|
|
14
|
+
/* The entries header is just the title plus the total count, no eyebrow or lede. */
|
|
15
|
+
.page-count { font-size: var(--text-sm); color: var(--text-dim); }
|
|
14
16
|
|
|
15
17
|
/* ---- Filter form ---- */
|
|
16
|
-
.filters { display: grid; grid-template-columns: minmax(0, 2fr) 200px 200px
|
|
18
|
+
.filters { display: grid; grid-template-columns: minmax(0, 2fr) 200px 200px; gap: var(--space-3); align-items: end; margin-bottom: var(--space-8); }
|
|
17
19
|
@media (max-width: 768px) { .filters { grid-template-columns: 1fr; } }
|
|
18
20
|
|
|
19
21
|
/* ---- Entries region ---- */
|
|
@@ -35,52 +37,141 @@
|
|
|
35
37
|
.partial-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-4); }
|
|
36
38
|
.partial-count { font-size: var(--text-sm); color: var(--text-dim); }
|
|
37
39
|
|
|
40
|
+
/* ---- Screen-reader-only ---- */
|
|
41
|
+
/* Present in the DOM and the accessibility tree, absent from layout, and never focusable. */
|
|
42
|
+
.visually-hidden {
|
|
43
|
+
position: absolute;
|
|
44
|
+
width: 1px;
|
|
45
|
+
height: 1px;
|
|
46
|
+
margin: -1px;
|
|
47
|
+
padding: 0;
|
|
48
|
+
overflow: hidden;
|
|
49
|
+
clip: rect(0 0 0 0);
|
|
50
|
+
white-space: nowrap;
|
|
51
|
+
border: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
/* ---- Project groups ---- */
|
|
39
55
|
.project-group { margin-bottom: var(--space-10); }
|
|
40
56
|
.project-header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-4); }
|
|
41
57
|
.project-name { font: 500 var(--text-lg)/1.2 var(--font-mono); color: var(--text); letter-spacing: -0.01em; }
|
|
42
58
|
.batch { margin-bottom: var(--space-6); }
|
|
43
59
|
.batch-header { display: flex; align-items: baseline; gap: var(--space-3); margin-bottom: var(--space-2); }
|
|
44
|
-
.batch-
|
|
60
|
+
.batch-meta { font: 400 var(--text-xs)/1 var(--font-sans); color: var(--text-faint); }
|
|
45
61
|
|
|
46
62
|
/* ---- Entry table cells ----
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.
|
|
51
|
-
.
|
|
52
|
-
.file
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
The table lays out fixed with a colgroup so the title column takes the remainder and the row
|
|
64
|
+
never overflows; select, status, file, and actions are the fixed columns. The status pill has
|
|
65
|
+
its own column, so the title truncates at one consistent point and the pill stays tight. */
|
|
66
|
+
.col-select { width: 48px; }
|
|
67
|
+
.col-status { width: 106px; }
|
|
68
|
+
.col-file { width: 200px; }
|
|
69
|
+
.col-actions { width: 120px; }
|
|
70
|
+
.entry-head {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: var(--space-2);
|
|
74
|
+
}
|
|
75
|
+
.entry-title {
|
|
76
|
+
flex: 1 1 auto;
|
|
77
|
+
min-width: 0;
|
|
78
|
+
font: 450 var(--text-base)/1.4 var(--font-sans);
|
|
79
|
+
color: var(--text);
|
|
58
80
|
white-space: nowrap;
|
|
59
81
|
overflow: hidden;
|
|
60
82
|
text-overflow: ellipsis;
|
|
61
83
|
}
|
|
62
|
-
.entry-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
|
|
84
|
+
.entry-filer {
|
|
85
|
+
flex-shrink: 0;
|
|
86
|
+
font-size: var(--text-xs);
|
|
87
|
+
color: var(--text-faint);
|
|
88
|
+
white-space: nowrap;
|
|
89
|
+
}
|
|
90
|
+
.cell-file {
|
|
91
|
+
font-family: var(--font-mono);
|
|
92
|
+
font-size: var(--text-xs);
|
|
93
|
+
color: var(--text-faint);
|
|
94
|
+
white-space: nowrap;
|
|
66
95
|
overflow: hidden;
|
|
96
|
+
text-overflow: ellipsis;
|
|
67
97
|
}
|
|
68
|
-
.cell-filedby { color: var(--text-dim); white-space: nowrap; }
|
|
69
|
-
.constraint-summary { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-dim); white-space: nowrap; }
|
|
70
98
|
|
|
71
99
|
/* ---- Row actions and batch approve ----
|
|
72
100
|
A control mid-request ignores further activations: htmx marks the requesting element with
|
|
73
101
|
.htmx-request while the request is in flight, and pointer-events drops the duplicate click a
|
|
74
102
|
double-click would otherwise fire. */
|
|
75
103
|
.htmx-request { pointer-events: none; }
|
|
76
|
-
|
|
77
|
-
|
|
104
|
+
/* The cloudy-ui custom checkbox: an appearance:none box on the sunk plane, accent-filled with a
|
|
105
|
+
clip-path check when checked. The select-all also takes a dash in its indeterminate state (a
|
|
106
|
+
partially selected batch), which the native accent-color box could not express. */
|
|
107
|
+
.cell-select input[type="checkbox"],
|
|
108
|
+
.batch-header .select-all {
|
|
109
|
+
width: 15px;
|
|
110
|
+
height: 15px;
|
|
111
|
+
appearance: none;
|
|
112
|
+
background: var(--bg-sunken);
|
|
113
|
+
border: 1px solid var(--line);
|
|
114
|
+
border-radius: calc(var(--radius) * 0.5);
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
flex-shrink: 0;
|
|
117
|
+
position: relative;
|
|
118
|
+
transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
|
|
119
|
+
}
|
|
120
|
+
.cell-select input[type="checkbox"]:hover,
|
|
121
|
+
.batch-header .select-all:hover { border-color: var(--line-strong); }
|
|
122
|
+
.cell-select input[type="checkbox"]:checked,
|
|
123
|
+
.batch-header .select-all:checked,
|
|
124
|
+
.cell-select input[type="checkbox"]:indeterminate,
|
|
125
|
+
.batch-header .select-all:indeterminate {
|
|
126
|
+
background: var(--accent);
|
|
127
|
+
border-color: var(--accent);
|
|
128
|
+
}
|
|
129
|
+
.cell-select input[type="checkbox"]:checked::after,
|
|
130
|
+
.batch-header .select-all:checked::after {
|
|
131
|
+
content: '';
|
|
132
|
+
position: absolute;
|
|
133
|
+
inset: 3px;
|
|
134
|
+
background: var(--text-on-accent);
|
|
135
|
+
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
|
136
|
+
}
|
|
137
|
+
.cell-select input[type="checkbox"]:indeterminate::after,
|
|
138
|
+
.batch-header .select-all:indeterminate::after {
|
|
139
|
+
content: '';
|
|
140
|
+
position: absolute;
|
|
141
|
+
left: 3px;
|
|
142
|
+
right: 3px;
|
|
143
|
+
top: 50%;
|
|
144
|
+
height: 2px;
|
|
145
|
+
transform: translateY(-50%);
|
|
146
|
+
background: var(--text-on-accent);
|
|
147
|
+
border-radius: 1px;
|
|
148
|
+
}
|
|
78
149
|
.cell-actions { white-space: nowrap; }
|
|
79
150
|
.cell-actions .btn + .btn { margin-left: var(--space-2); }
|
|
80
|
-
.
|
|
151
|
+
.table-wrap tbody td.cell-actions { padding-left: var(--space-1); padding-right: var(--space-1); }
|
|
152
|
+
.batch-bar {
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
margin-bottom: var(--space-4);
|
|
156
|
+
/* Sticks just below the 56px navbar so the approve button stays reachable while rows scroll
|
|
157
|
+
under it; the background and z-index stop table rows from showing through the bar. */
|
|
158
|
+
position: sticky;
|
|
159
|
+
top: 56px;
|
|
160
|
+
z-index: 10;
|
|
161
|
+
padding: var(--space-2) 0;
|
|
162
|
+
background: var(--bg);
|
|
163
|
+
}
|
|
164
|
+
.batch-header .select-all { align-self: center; }
|
|
165
|
+
/* ---- Collapsible project sections and batches ----
|
|
166
|
+
The chevron points down expanded and turns when collapsed; the state is applied by dashboard.js
|
|
167
|
+
from localStorage, so the server ships everything open and the viewer's choice re-applies. */
|
|
168
|
+
.collapse-toggle { align-self: center; }
|
|
169
|
+
.collapse-toggle svg { transition: transform var(--dur-fast) var(--ease); }
|
|
170
|
+
.collapse-toggle[aria-expanded="false"] svg { transform: rotate(-90deg); }
|
|
171
|
+
.project-header .collapse-toggle, .batch-header .collapse-toggle { margin-left: auto; }
|
|
81
172
|
.notice-list { margin: 0; padding-left: var(--space-5); display: flex; flex-direction: column; gap: var(--space-1); }
|
|
82
173
|
|
|
83
|
-
/* ---- Diff view: the anchored snippet vs the entry's text, mono on sunken, anchor
|
|
174
|
+
/* ---- Diff view: the anchored snippet vs the entry's text, mono on sunken, anchor distinct ---- */
|
|
84
175
|
.diff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); margin-bottom: var(--space-8); }
|
|
85
176
|
@media (max-width: 900px) { .diff-grid { grid-template-columns: 1fr; } }
|
|
86
177
|
.diff-code {
|
|
@@ -107,37 +198,61 @@
|
|
|
107
198
|
.empty-icon { margin: 0 auto var(--space-4); width: 32px; height: 32px; color: var(--text-faint); display: flex; align-items: center; justify-content: center; }
|
|
108
199
|
.empty-state h3 { font: 500 var(--text-lg)/1.3 var(--font-sans); color: var(--text); margin-bottom: var(--space-2); }
|
|
109
200
|
.empty-state p { font-size: var(--text-sm); color: var(--text-dim); max-width: 420px; margin: 0 auto var(--space-4); }
|
|
201
|
+
.empty-actions { display: flex; gap: var(--space-2); justify-content: center; }
|
|
110
202
|
|
|
111
203
|
/* ---- Error box (cloned from #error-box by dashboard.js) ---- */
|
|
112
204
|
.callout-content { min-width: 0; }
|
|
113
205
|
.callout-actions { margin-top: var(--space-2); }
|
|
114
206
|
|
|
115
207
|
/* ---- Entry editor ---- */
|
|
116
|
-
/* The one live region
|
|
117
|
-
.
|
|
118
|
-
position: absolute;
|
|
119
|
-
width: 1px;
|
|
120
|
-
height: 1px;
|
|
121
|
-
margin: -1px;
|
|
122
|
-
padding: 0;
|
|
123
|
-
overflow: hidden;
|
|
124
|
-
clip: rect(0 0 0 0);
|
|
125
|
-
white-space: nowrap;
|
|
126
|
-
border: 0;
|
|
127
|
-
}
|
|
128
|
-
.editor-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--space-8); align-items: start; }
|
|
208
|
+
/* The one live region, kept out of sight by the shared .visually-hidden utility above. */
|
|
209
|
+
.editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-8); align-items: start; }
|
|
129
210
|
.editor-main { min-width: 0; }
|
|
130
211
|
.editor-rail { display: flex; flex-direction: column; gap: var(--space-6); position: sticky; top: var(--space-8); }
|
|
131
212
|
@media (max-width: 900px) {
|
|
132
213
|
.editor-grid { grid-template-columns: 1fr; }
|
|
133
214
|
.editor-rail { position: static; }
|
|
134
215
|
}
|
|
216
|
+
/* The editor's title card header: the title falls back to the anchor text, which can be one long
|
|
217
|
+
unbroken string, so it wraps inside the card instead of overflowing or shoving the status pill. */
|
|
218
|
+
.editor-title { min-width: 0; overflow-wrap: anywhere; }
|
|
219
|
+
/* The entry text ships verbatim, often carrying markdown backticks and significant whitespace;
|
|
220
|
+
mono gives every glyph, space included, one full cell so both stay countable while editing. */
|
|
221
|
+
#editor-text { font-family: var(--font-mono); }
|
|
222
|
+
|
|
223
|
+
/* ---- Editor prev/next rails ----
|
|
224
|
+
One full-height fixed strip per side, below the 56px navbar, so the previous and next entries are
|
|
225
|
+
a single click away while reading. The strip sits inside the 48px page padding, clear of content. */
|
|
226
|
+
.editor-nav-btn {
|
|
227
|
+
position: fixed;
|
|
228
|
+
top: 56px;
|
|
229
|
+
bottom: 0;
|
|
230
|
+
width: 44px;
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
justify-content: center;
|
|
234
|
+
color: var(--text-dim);
|
|
235
|
+
text-decoration: none;
|
|
236
|
+
z-index: 40;
|
|
237
|
+
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
|
|
238
|
+
}
|
|
239
|
+
.editor-nav-prev { left: 0; }
|
|
240
|
+
.editor-nav-next { right: 0; }
|
|
241
|
+
.editor-nav-btn:hover { background: var(--bg-hover); color: var(--text); }
|
|
242
|
+
.editor-nav-btn.is-disabled { color: var(--text-faint); opacity: 0.4; pointer-events: none; }
|
|
243
|
+
.editor-nav-btn svg { width: 18px; height: 18px; }
|
|
244
|
+
@media (max-width: 900px) { .editor-nav { display: none; } }
|
|
135
245
|
#editor-region > .callout { margin-bottom: var(--space-4); }
|
|
136
246
|
#editor-region > .btn { margin-top: var(--space-4); }
|
|
137
247
|
.editor-actions { margin-top: var(--space-4); display: flex; align-items: center; gap: var(--space-2); }
|
|
138
248
|
/* The in-flight indicator for an approve or reject from the editor: htmx's own rules fade it in. */
|
|
139
249
|
.editor-actions .htmx-indicator { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); color: var(--text-dim); }
|
|
140
250
|
|
|
251
|
+
/* The notes card: the agent's row reuses the constraint row shape; the human's textarea and its
|
|
252
|
+
save button sit below it. */
|
|
253
|
+
.notes-field { margin-top: var(--space-2); }
|
|
254
|
+
.notes-actions { margin-top: var(--space-2); display: flex; justify-content: flex-end; }
|
|
255
|
+
|
|
141
256
|
/* Constraint panel: live count and placeholder checklist; the server owns the refusal. */
|
|
142
257
|
.constraint-row { display: flex; gap: var(--space-3); padding: 6px 0; }
|
|
143
258
|
.constraint-row + .constraint-row { border-top: 1px solid var(--line); }
|
|
@@ -149,8 +264,28 @@
|
|
|
149
264
|
.placeholder-item { display: flex; align-items: center; gap: var(--space-2); }
|
|
150
265
|
.placeholder-state { font-size: var(--text-xs); color: var(--text-dim); min-width: 52px; }
|
|
151
266
|
.placeholder-state.is-present { color: var(--success); }
|
|
267
|
+
|
|
268
|
+
/* The context card's secondary block: the provenance rows (hash, filer, stale note) collapsed
|
|
269
|
+
behind a summary, so the anchor and the agent-supplied context rows lead the card. */
|
|
270
|
+
.context-secondary { margin-top: var(--space-3); }
|
|
271
|
+
.context-secondary summary { cursor: pointer; font-size: var(--text-xs); color: var(--text-dim); }
|
|
272
|
+
.context-secondary .info-table { margin-top: var(--space-2); }
|
|
273
|
+
|
|
274
|
+
/* Repo row link: the brand icon beside the shortened owner/repo path, linking out to the host. */
|
|
275
|
+
.repo-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text); text-decoration: none; }
|
|
276
|
+
.repo-link:hover { color: var(--accent); }
|
|
277
|
+
.repo-icon { width: 14px; height: 14px; flex-shrink: 0; fill: currentColor; opacity: 0.7; }
|
|
152
278
|
.placeholder-token { font: 400 var(--text-sm)/1 var(--font-mono); color: var(--text); background: var(--bg-sunken); border: 1px solid var(--line); border-radius: var(--radius); padding: 2px 6px; }
|
|
153
279
|
|
|
280
|
+
/* Entry images: one image per row at the card's width, a remove form below each, and the
|
|
281
|
+
one-file upload form closing the card. The file input flexes to whatever width remains. */
|
|
282
|
+
.image-list { list-style: none; margin: 0 0 var(--space-3); padding: 0; display: flex; flex-direction: column; gap: var(--space-3); }
|
|
283
|
+
.entry-image { display: block; max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: var(--radius); }
|
|
284
|
+
.image-skip { margin: 0; font-size: var(--text-xs); color: var(--text-dim); }
|
|
285
|
+
.image-remove { margin-top: var(--space-1); }
|
|
286
|
+
.image-upload { display: flex; gap: var(--space-2); align-items: center; }
|
|
287
|
+
.image-upload .input { flex: 1; min-width: 0; }
|
|
288
|
+
|
|
154
289
|
/* Revision history: newest first; the text clamps like the list's entry text. */
|
|
155
290
|
.history-empty { font-size: var(--text-sm); color: var(--text-faint); padding: var(--space-2) 0; }
|
|
156
291
|
.revision-list { list-style: none; margin: 0; padding: 0; }
|
|
@@ -60,6 +60,34 @@
|
|
|
60
60
|
form.addEventListener("htmx:sendError", showFilterError);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// --- Filter auto-submit ---
|
|
64
|
+
// The Apply button is gone: changing a select or typing in the search fires the filter request
|
|
65
|
+
// itself. requestSubmit() reaches htmx's submit listener on the form; the debounce collapses a
|
|
66
|
+
// burst of keystrokes into one request. Only the change and input events below trigger a submit,
|
|
67
|
+
// and both fire on user action alone, so a programmatic value reset (which sets .value directly
|
|
68
|
+
// and emits no event) never re-fires.
|
|
69
|
+
function submitFilters() {
|
|
70
|
+
const target = document.getElementById("filters");
|
|
71
|
+
if (target && typeof target.requestSubmit === "function") {
|
|
72
|
+
target.requestSubmit();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let searchTimer;
|
|
77
|
+
function scheduleSearchSubmit() {
|
|
78
|
+
clearTimeout(searchTimer);
|
|
79
|
+
searchTimer = setTimeout(submitFilters, 300);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (form) {
|
|
83
|
+
const statusSelect = document.getElementById("status");
|
|
84
|
+
const projectSelect = document.getElementById("project");
|
|
85
|
+
const searchInput = document.getElementById("q");
|
|
86
|
+
statusSelect?.addEventListener("change", submitFilters);
|
|
87
|
+
projectSelect?.addEventListener("change", submitFilters);
|
|
88
|
+
searchInput?.addEventListener("input", scheduleSearchSubmit);
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
// --- Style guide save errors ---
|
|
64
92
|
// A failed save (HTTP error or network error) puts the error box from the page's
|
|
65
93
|
// #style-guide-error-box template above the form; the retry button re-submits the save form.
|
|
@@ -166,4 +194,117 @@
|
|
|
166
194
|
refreshEditorMetrics();
|
|
167
195
|
textarea.focus();
|
|
168
196
|
});
|
|
197
|
+
|
|
198
|
+
// --- Batch select-all and live selection count ---
|
|
199
|
+
// The list region is swapped wholesale on every filter and action round, so no listener bound to
|
|
200
|
+
// a checkbox or the button survives a swap: change events are delegated on document, and one pass
|
|
201
|
+
// after each swap recomputes the select-all states and the approve button's count. All of it is
|
|
202
|
+
// progressive enhancement — without JS the approve button keeps its static label, stays enabled,
|
|
203
|
+
// and the native form still submits the checked rows.
|
|
204
|
+
const DRAFT_SELECTOR = 'input[type="checkbox"][name="id"]';
|
|
205
|
+
const SELECT_ALL_SELECTOR = 'input[type="checkbox"].select-all';
|
|
206
|
+
|
|
207
|
+
function selectionCount() {
|
|
208
|
+
return document.querySelectorAll(`#batch-form ${DRAFT_SELECTOR}:checked`).length;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function updateApproveButton() {
|
|
212
|
+
const button = document.querySelector('#batch-form .batch-bar button[type="submit"]');
|
|
213
|
+
if (!button) return;
|
|
214
|
+
const count = selectionCount();
|
|
215
|
+
button.textContent = count === 0 ? "Approve selected" : `Approve ${count}`;
|
|
216
|
+
button.disabled = count === 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function updateSelectAll(selectAll) {
|
|
220
|
+
const batch = selectAll.closest(".batch");
|
|
221
|
+
if (!batch) return;
|
|
222
|
+
const drafts = batch.querySelectorAll(DRAFT_SELECTOR);
|
|
223
|
+
const checked = batch.querySelectorAll(`${DRAFT_SELECTOR}:checked`).length;
|
|
224
|
+
selectAll.checked = drafts.length > 0 && checked === drafts.length;
|
|
225
|
+
selectAll.indeterminate = checked > 0 && checked < drafts.length;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function refreshSelection() {
|
|
229
|
+
for (const selectAll of document.querySelectorAll(SELECT_ALL_SELECTOR)) {
|
|
230
|
+
updateSelectAll(selectAll);
|
|
231
|
+
}
|
|
232
|
+
updateApproveButton();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
document.addEventListener("change", (event) => {
|
|
236
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
237
|
+
if (!target) return;
|
|
238
|
+
if (target.matches(SELECT_ALL_SELECTOR)) {
|
|
239
|
+
const batch = target.closest(".batch");
|
|
240
|
+
if (batch) {
|
|
241
|
+
for (const draft of batch.querySelectorAll(DRAFT_SELECTOR)) {
|
|
242
|
+
draft.checked = target.checked;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
refreshSelection();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
document.addEventListener("htmx:afterSwap", refreshSelection);
|
|
250
|
+
|
|
251
|
+
// --- Collapsible project sections and batches ---
|
|
252
|
+
// The server renders every section and batch open; JS collapses the ones this viewer collapsed
|
|
253
|
+
// before, from localStorage, and persists each toggle. Re-applied after every swap because a
|
|
254
|
+
// filter or action round replaces the whole list region. Without JS the toggles stay inert and
|
|
255
|
+
// everything stays open.
|
|
256
|
+
const COLLAPSE_STORAGE = "reviewzy.list-collapsed";
|
|
257
|
+
|
|
258
|
+
function readCollapsed() {
|
|
259
|
+
try {
|
|
260
|
+
const raw = localStorage.getItem(COLLAPSE_STORAGE);
|
|
261
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
262
|
+
// A parsed array is not a key map: property sets on it would survive only until the next
|
|
263
|
+
// JSON.stringify, which drops them, so persistence would silently stop.
|
|
264
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
265
|
+
} catch {
|
|
266
|
+
return {};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function persistCollapsed(key, collapsed) {
|
|
271
|
+
const map = readCollapsed();
|
|
272
|
+
if (collapsed) {
|
|
273
|
+
map[key] = true;
|
|
274
|
+
} else {
|
|
275
|
+
delete map[key];
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
localStorage.setItem(COLLAPSE_STORAGE, JSON.stringify(map));
|
|
279
|
+
} catch {
|
|
280
|
+
// Storage can be unavailable (private windows); the toggle still works for the page.
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function applyCollapseState() {
|
|
285
|
+
const collapsed = readCollapsed();
|
|
286
|
+
for (const toggle of document.querySelectorAll("[data-collapse-toggle]")) {
|
|
287
|
+
const key = toggle.dataset.collapseKey ?? "";
|
|
288
|
+
const target = document.getElementById(toggle.dataset.collapseTarget ?? "");
|
|
289
|
+
if (!target) continue;
|
|
290
|
+
const hidden = collapsed[key] === true;
|
|
291
|
+
target.hidden = hidden;
|
|
292
|
+
toggle.setAttribute("aria-expanded", String(!hidden));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
document.addEventListener("click", (event) => {
|
|
297
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
298
|
+
const toggle = target ? target.closest("[data-collapse-toggle]") : null;
|
|
299
|
+
if (!toggle) return;
|
|
300
|
+
const el = document.getElementById(toggle.dataset.collapseTarget ?? "");
|
|
301
|
+
if (!el) return;
|
|
302
|
+
const hide = !el.hidden;
|
|
303
|
+
el.hidden = hide;
|
|
304
|
+
toggle.setAttribute("aria-expanded", String(!hide));
|
|
305
|
+
persistCollapsed(toggle.dataset.collapseKey ?? "", hide);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
applyCollapseState();
|
|
309
|
+
document.addEventListener("htmx:afterSwap", applyCollapseState);
|
|
169
310
|
})();
|
|
Binary file
|