reviewzy 0.3.0 → 0.4.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 +3 -3
- package/package.json +2 -2
- package/src/dashboard/mount.ts +20 -3
- package/src/dashboard/static/components.css +1196 -451
- package/src/dashboard/static/cursor-rules.js +30 -34
- package/src/dashboard/static/cursor.css +118 -89
- package/src/dashboard/static/cursor.js +1636 -245
- package/src/dashboard/static/dashboard.css +275 -179
- package/src/dashboard/static/dashboard.js +330 -185
- package/src/dashboard/static/tokens.css +240 -54
- package/src/dashboard/static/ui.js +666 -171
- package/src/dashboard/views.tsx +1138 -841
- package/src/dashboard/word-diff.ts +18 -25
- package/src/db/migrations.ts +5 -6
- package/src/db/queries.ts +37 -0
- package/src/mcp/file-entries.ts +1 -1
- package/src/mcp/list-entries.ts +11 -2
|
@@ -1,52 +1,100 @@
|
|
|
1
1
|
// reviewzy dashboard behaviors. Loads as a module after htmx.min.js.
|
|
2
|
-
// The
|
|
3
|
-
//
|
|
4
|
-
|
|
2
|
+
// The sidebar's rail and drawer, the selects, the tabs, the theme toggle, the toasts and the
|
|
3
|
+
// table header label boxes come from ui.js; initTables, initTabs and initDismiss are re-run
|
|
4
|
+
// after every htmx swap so a freshly rendered table head gets its boxes and its selection state
|
|
5
|
+
// machine, the OOB tab strip gets its ink, and a banner the swap replaced out of band gets its
|
|
6
|
+
// dismiss control back.
|
|
7
|
+
import { initDismiss, initScrollFade, initTables, initTabs, initViewportWidth, showToast } from "./ui.js";
|
|
5
8
|
|
|
6
9
|
(() => {
|
|
7
10
|
"use strict";
|
|
8
11
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
12
|
+
// The shell is a sidebar shell, so the full-bleed sticky bar measures the page column, not the
|
|
13
|
+
// viewport: --vp-w on the column keeps the stuck bar inside the page beside the sidebar.
|
|
14
|
+
initViewportWidth(document.querySelector("main.page") ?? undefined);
|
|
15
|
+
|
|
16
|
+
// --- Error toasts ---
|
|
17
|
+
// A failed htmx round (HTTP error or network error) raises a transient toast carrying the
|
|
18
|
+
// failure and a Retry button. The toast dies on its own timer; the durable way out is the
|
|
19
|
+
// control the failed request came from, which the failure never removes: the filters form
|
|
20
|
+
// re-renders the list, and the style guide form keeps the typed values.
|
|
21
|
+
function wireRetry(toast, retry) {
|
|
22
|
+
const body = toast.querySelector(".toast-body");
|
|
23
|
+
if (!body) return;
|
|
24
|
+
const button = document.createElement("button");
|
|
25
|
+
button.type = "button";
|
|
26
|
+
button.className = "btn btn-secondary btn-sm";
|
|
27
|
+
button.textContent = "Retry";
|
|
28
|
+
button.addEventListener("click", retry);
|
|
29
|
+
body.appendChild(button);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function raiseErrorToast(title, message, retry) {
|
|
33
|
+
const toast = showToast("danger", title, message);
|
|
34
|
+
if (toast) wireRetry(toast, retry);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// htmx listens for submit directly on the form it processed, so a native requestSubmit
|
|
38
|
+
// reaches it; the reload fallback covers the case where htmx did not load.
|
|
39
|
+
function submitForm(form) {
|
|
40
|
+
if (form && typeof form.requestSubmit === "function") {
|
|
41
|
+
form.requestSubmit();
|
|
42
|
+
} else {
|
|
43
|
+
window.location.reload();
|
|
30
44
|
}
|
|
31
|
-
list.replaceChildren(box);
|
|
32
45
|
}
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
const filtersForm = document.getElementById("filters");
|
|
48
|
+
if (filtersForm) {
|
|
49
|
+
const retryFilters = () => submitForm(document.getElementById("filters"));
|
|
50
|
+
const raiseFilterError = () => raiseErrorToast("The list could not be loaded", "The filter request failed. Try it again.", retryFilters);
|
|
51
|
+
filtersForm.addEventListener("htmx:responseError", raiseFilterError);
|
|
52
|
+
filtersForm.addEventListener("htmx:sendError", raiseFilterError);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Delegated on document: a successful swap replaces the style guide form, so listeners bound
|
|
56
|
+
// to it would die. The event target names the form the failure came from.
|
|
57
|
+
document.addEventListener("htmx:responseError", (event) => {
|
|
58
|
+
if (!(event.target instanceof Element) || event.target.id !== "style-guide-form") return;
|
|
59
|
+
raiseErrorToast("The guide could not be saved", "The save request failed. Try it again.", () => submitForm(document.getElementById("style-guide-form")));
|
|
60
|
+
});
|
|
61
|
+
document.addEventListener("htmx:sendError", (event) => {
|
|
62
|
+
if (!(event.target instanceof Element) || event.target.id !== "style-guide-form") return;
|
|
63
|
+
raiseErrorToast("The guide could not be saved", "The save request failed. Try it again.", () => submitForm(document.getElementById("style-guide-form")));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The editor's and the list's own failures (save, reject, notes, images, row and batch actions):
|
|
67
|
+
// htmx swaps nothing on an error, so without a toast the click reads as ignored. Retry re-fires
|
|
68
|
+
// the control that failed; the title names which surface failed.
|
|
69
|
+
for (const type of ["htmx:responseError", "htmx:sendError"]) {
|
|
70
|
+
document.addEventListener(type, (event) => {
|
|
71
|
+
const el = event.target instanceof Element ? event.target : null;
|
|
72
|
+
if (!el) return;
|
|
73
|
+
// A failed live diff is a preview miss, not a failed save: leave the marks and stay quiet.
|
|
74
|
+
if (el.id === "editor-text") return;
|
|
75
|
+
const inEditor = el.closest("#editor-view") !== null;
|
|
76
|
+
const inList = el.closest("#batch-form") !== null;
|
|
77
|
+
if (!inEditor && !inList) return;
|
|
78
|
+
const form = el.closest("form");
|
|
79
|
+
raiseErrorToast(
|
|
80
|
+
inEditor ? "The change was not saved" : "The action did not finish",
|
|
81
|
+
"The request failed. Try it again.",
|
|
82
|
+
() => {
|
|
83
|
+
if (el instanceof HTMLButtonElement) el.click();
|
|
84
|
+
else if (form) submitForm(form);
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
});
|
|
37
88
|
}
|
|
38
89
|
|
|
39
90
|
// --- Filter auto-submit ---
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
91
|
+
// Changing a select or typing in the search fires the filter request itself. requestSubmit()
|
|
92
|
+
// reaches htmx's submit listener on the form; the debounce collapses a burst of keystrokes
|
|
93
|
+
// into one request. Only the change and input events below trigger a submit, and both fire on
|
|
94
|
+
// user action alone, so a programmatic value reset (which sets .value directly and emits no
|
|
95
|
+
// event) never re-fires.
|
|
45
96
|
function submitFilters() {
|
|
46
|
-
|
|
47
|
-
if (target && typeof target.requestSubmit === "function") {
|
|
48
|
-
target.requestSubmit();
|
|
49
|
-
}
|
|
97
|
+
submitForm(document.getElementById("filters"));
|
|
50
98
|
}
|
|
51
99
|
|
|
52
100
|
let searchTimer;
|
|
@@ -55,59 +103,228 @@ import { initTables } from "./ui.js";
|
|
|
55
103
|
searchTimer = setTimeout(submitFilters, 300);
|
|
56
104
|
}
|
|
57
105
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
106
|
+
// The hidden inputs ship disabled so a no-JS submit reads the noscript native selects instead
|
|
107
|
+
// of a stale hidden value; with the widgets live they carry the picked value.
|
|
108
|
+
for (const field of document.querySelectorAll('#filters input[data-filter-field]')) {
|
|
109
|
+
field.disabled = false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// With the widget path live, the hidden status field is the only status the form sends. A submit
|
|
113
|
+
// button's own name would ride along otherwise, and on a submit with no submitter (requestSubmit,
|
|
114
|
+
// or a native fallback when htmx did not load) the form's default button adds `status=""` ahead
|
|
115
|
+
// of the hidden value, which hono reads first. No-JS keeps the names for its native path.
|
|
116
|
+
for (const button of document.querySelectorAll('#filters button[type="submit"]')) {
|
|
117
|
+
button.removeAttribute("name");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ui.js's select controller commits the picked option onto the root before the change event;
|
|
121
|
+
// the option's own data-value holds what the form submits.
|
|
122
|
+
for (const root of document.querySelectorAll('#filters .select[data-filter]')) {
|
|
123
|
+
root.addEventListener("change", () => {
|
|
124
|
+
const field = root.dataset.filter ?? "";
|
|
125
|
+
const picked = root.querySelector('.menu-item[aria-selected="true"]');
|
|
126
|
+
const hidden = document.querySelector(`#filters input[data-filter-field="${field}"]`);
|
|
127
|
+
if (hidden && picked) {
|
|
128
|
+
hidden.value = picked.getAttribute("data-value") ?? "";
|
|
129
|
+
submitFilters();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (filtersForm) {
|
|
61
135
|
const searchInput = document.getElementById("q");
|
|
62
|
-
statusSelect?.addEventListener("change", submitFilters);
|
|
63
|
-
projectSelect?.addEventListener("change", submitFilters);
|
|
64
136
|
searchInput?.addEventListener("input", scheduleSearchSubmit);
|
|
137
|
+
// Enter in the search field would submit through the first tab button (Open) and silently
|
|
138
|
+
// drop the chosen status; the debounced path already carries it from the hidden field, so
|
|
139
|
+
// Enter takes that path too.
|
|
140
|
+
searchInput?.addEventListener("keydown", (event) => {
|
|
141
|
+
if (event.key !== "Enter") return;
|
|
142
|
+
// An IME confirms a composition with Enter; that key belongs to the composition.
|
|
143
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
clearTimeout(searchTimer);
|
|
146
|
+
submitFilters();
|
|
147
|
+
});
|
|
65
148
|
}
|
|
66
149
|
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
150
|
+
// A status tab submits the form itself: the button carries the new value, the hidden field
|
|
151
|
+
// mirrors it so every other submit path (the search debounce, the project select) keeps it, and
|
|
152
|
+
// the enhanced path prevents the native submit whose name would duplicate it. Without JS the
|
|
153
|
+
// buttons keep their names and the native path carries the status.
|
|
154
|
+
let restoreStatusTabFocus = false;
|
|
155
|
+
document.addEventListener("click", (event) => {
|
|
156
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
157
|
+
const tab = target ? target.closest("#filters .tab") : null;
|
|
158
|
+
if (!tab) return;
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
const hidden = document.querySelector('#filters input[data-filter-field="status"]');
|
|
161
|
+
if (hidden) hidden.value = tab.value;
|
|
162
|
+
restoreStatusTabFocus = true;
|
|
163
|
+
submitFilters();
|
|
164
|
+
});
|
|
73
165
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
166
|
+
// --- Swap focus and announcements ---
|
|
167
|
+
// An htmx swap replaces the node the user acted on, so focus falls to <body>; the tab strip's
|
|
168
|
+
// out-of-band replacement does the same on the page's primary control. Put focus back where
|
|
169
|
+
// the keyboard user left it, and announce the outcome through a persistent live region (an
|
|
170
|
+
// out-of-band banner arrives already populated, which a live region does not reliably read).
|
|
171
|
+
let editorFocusAfterSwap = null;
|
|
172
|
+
let listActionFocus = false;
|
|
173
|
+
let listActionRowHref = null;
|
|
174
|
+
document.addEventListener("htmx:beforeRequest", (event) => {
|
|
175
|
+
const el = event.target instanceof Element ? event.target : null;
|
|
176
|
+
if (!el) return;
|
|
177
|
+
// A row action or the batch submit is replaced by the list swap; the row the user acted on
|
|
178
|
+
// (or the outcome banner) is where their focus belongs afterwards.
|
|
179
|
+
if (el.closest("#batch-form")) {
|
|
180
|
+
listActionFocus = true;
|
|
181
|
+
listActionRowHref = el.closest("tr")?.querySelector('a[href^="/entries/"]')?.getAttribute("href") ?? null;
|
|
182
|
+
}
|
|
183
|
+
// The live diff is the textarea's own request: it swaps the current-text card, and focus and
|
|
184
|
+
// announcements stay exactly where the typist left them.
|
|
185
|
+
if (el.id === "editor-text") return;
|
|
186
|
+
if (!el.closest("#editor-view")) return;
|
|
187
|
+
// Any real editor action aborts a pending preview, or its late response would overwrite the
|
|
188
|
+
// freshly saved marks with ones computed from older text.
|
|
189
|
+
if (typeof htmx !== "undefined") {
|
|
190
|
+
const pending = document.getElementById("editor-text");
|
|
191
|
+
if (pending) htmx.trigger(pending, "htmx:abort");
|
|
97
192
|
}
|
|
98
|
-
|
|
193
|
+
if (el.id === "editor-form") editorFocusAfterSwap = "save";
|
|
194
|
+
else if (el.id === "notes-form") editorFocusAfterSwap = "notes";
|
|
195
|
+
else if (el.closest("#image-upload") !== null || el.closest('form[action*="/images"]') !== null) editorFocusAfterSwap = "image";
|
|
196
|
+
else editorFocusAfterSwap = "transition";
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
function announceListOutcome() {
|
|
200
|
+
const live = document.getElementById("list-live");
|
|
201
|
+
if (!live) return;
|
|
202
|
+
const banner = document.querySelector("#page-banner .banner");
|
|
203
|
+
const meta = document.querySelector("#list-meta .pagination-count");
|
|
204
|
+
const message = [banner?.textContent?.trim(), meta?.textContent?.trim()].filter(Boolean).join(". ");
|
|
205
|
+
if (message !== "" && message !== live.textContent) live.textContent = message;
|
|
99
206
|
}
|
|
100
207
|
|
|
101
|
-
|
|
102
|
-
|
|
208
|
+
function announceEditorOutcome(action) {
|
|
209
|
+
const live = document.getElementById("editor-live");
|
|
210
|
+
if (!live) return;
|
|
211
|
+
// Only a refusal speaks to the outcome; the read-only state callout is a standing fact.
|
|
212
|
+
const refusal = document.querySelector("#editor-view .callout-danger");
|
|
213
|
+
if (refusal) {
|
|
214
|
+
const title = refusal.querySelector(".callout-title")?.textContent?.trim();
|
|
215
|
+
const body = refusal.querySelector(".callout-body")?.textContent?.trim();
|
|
216
|
+
live.textContent = [title, body].filter(Boolean).join(". ");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (action === "notes") {
|
|
220
|
+
live.textContent = "Notes saved.";
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (action === "image") {
|
|
224
|
+
live.textContent = "Image updated.";
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const status = document.querySelector("#entry-status .tag")?.textContent?.trim();
|
|
228
|
+
if (status) live.textContent = `Entry is now ${status}.`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Keep the current tab inside its scroller on a deep link or after an out-of-band swap.
|
|
232
|
+
function revealActiveTab() {
|
|
233
|
+
for (const wrap of document.querySelectorAll(".status-scroll, .style-guide-projects")) {
|
|
234
|
+
const active = wrap.querySelector(".tab.active");
|
|
235
|
+
if (!active) continue;
|
|
236
|
+
const box = wrap.getBoundingClientRect();
|
|
237
|
+
const tab = active.getBoundingClientRect();
|
|
238
|
+
if (tab.left < box.left || tab.right > box.right) {
|
|
239
|
+
wrap.scrollLeft += tab.left - box.left - (box.width - tab.width) / 2;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The live diff re-renders the current-text card on every pause; keep the reader's place in it.
|
|
245
|
+
let currentPaneScroll = 0;
|
|
246
|
+
document.addEventListener("htmx:beforeSwap", (event) => {
|
|
247
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
248
|
+
if (target?.id !== "current-text") return;
|
|
249
|
+
currentPaneScroll = target.querySelector(".editor-pane")?.scrollTop ?? 0;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
document.addEventListener("htmx:afterSwap", (event) => {
|
|
253
|
+
// The live diff swaps only the current-text card; editor focus and announcements belong to the
|
|
254
|
+
// view and region swaps, so a preview must not consume a pending round's flag.
|
|
255
|
+
const swapTarget = event.target instanceof Element ? event.target : null;
|
|
256
|
+
const editorSwap = swapTarget !== null && (swapTarget.id === "editor-view" || swapTarget.id === "editor-region");
|
|
257
|
+
if (swapTarget?.id === "current-text") {
|
|
258
|
+
const pane = document.querySelector("#current-text .editor-pane");
|
|
259
|
+
if (pane) pane.scrollTop = currentPaneScroll;
|
|
260
|
+
}
|
|
261
|
+
if (restoreStatusTabFocus) {
|
|
262
|
+
restoreStatusTabFocus = false;
|
|
263
|
+
const active = document.querySelector("#status-tabs .tab.active") ?? document.querySelector("#status-tabs .tab");
|
|
264
|
+
active?.focus();
|
|
265
|
+
}
|
|
266
|
+
if (listActionFocus) {
|
|
267
|
+
listActionFocus = false;
|
|
268
|
+
const rowLink = listActionRowHref === null ? null : document.querySelector(`#entries-list a[href="${listActionRowHref}"]`);
|
|
269
|
+
listActionRowHref = null;
|
|
270
|
+
const banner = document.querySelector("#page-banner .banner");
|
|
271
|
+
const firstLink = document.querySelector("#entries-list .entry-link");
|
|
272
|
+
(rowLink ?? banner ?? firstLink)?.focus();
|
|
273
|
+
}
|
|
274
|
+
if (editorFocusAfterSwap !== null && editorSwap) {
|
|
275
|
+
const refused = document.querySelector("#editor-view .callout-danger") !== null;
|
|
276
|
+
const target =
|
|
277
|
+
editorFocusAfterSwap === "save"
|
|
278
|
+
? refused
|
|
279
|
+
? // A refusal names a fix in the text the user must edit; the caret goes back there.
|
|
280
|
+
document.querySelector("#editor-text")
|
|
281
|
+
: document.querySelector('#editor-form button[type="submit"]')
|
|
282
|
+
: editorFocusAfterSwap === "notes"
|
|
283
|
+
? document.querySelector('#notes-form button[type="submit"]')
|
|
284
|
+
: editorFocusAfterSwap === "image"
|
|
285
|
+
? document.querySelector("#image-upload button")
|
|
286
|
+
: document.querySelector("#editor-region");
|
|
287
|
+
target?.focus();
|
|
288
|
+
const action = editorFocusAfterSwap;
|
|
289
|
+
editorFocusAfterSwap = null;
|
|
290
|
+
announceEditorOutcome(action);
|
|
291
|
+
}
|
|
292
|
+
announceListOutcome();
|
|
293
|
+
revealActiveTab();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// A failed round swaps nothing, so a focus flag set for it must not steer the next, unrelated
|
|
297
|
+
// swap's focus.
|
|
298
|
+
for (const type of ["htmx:responseError", "htmx:sendError"]) {
|
|
299
|
+
document.addEventListener(type, () => {
|
|
300
|
+
restoreStatusTabFocus = false;
|
|
301
|
+
listActionFocus = false;
|
|
302
|
+
listActionRowHref = null;
|
|
303
|
+
editorFocusAfterSwap = null;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- Textareas ---
|
|
308
|
+
// Every data-autosize field grows to its content, capped at 60% of the viewport so the actions
|
|
309
|
+
// below a long text never leave the screen; only a genuinely long box scrolls. The page's CSS
|
|
310
|
+
// sets the resting min-height per field and the autosizer's inline height rides over it.
|
|
311
|
+
function autosizeTextareas() {
|
|
312
|
+
const cap = Math.round(window.innerHeight * 0.6);
|
|
313
|
+
for (const textarea of document.querySelectorAll("textarea[data-autosize]")) {
|
|
314
|
+
textarea.style.height = "auto";
|
|
315
|
+
textarea.style.height = `${Math.min(textarea.scrollHeight + 2, cap)}px`;
|
|
316
|
+
textarea.style.overflowY = textarea.scrollHeight + 2 > cap ? "auto" : "hidden";
|
|
317
|
+
}
|
|
318
|
+
}
|
|
103
319
|
|
|
104
320
|
// --- Editor live metrics ---
|
|
105
|
-
// The constraint
|
|
106
|
-
//
|
|
107
|
-
//
|
|
321
|
+
// The constraint row lives inside the htmx swap region now, so it re-renders with the swap;
|
|
322
|
+
// between swaps the char count and the placeholder checklist track the textarea as the user
|
|
323
|
+
// types. This is a convenience preview only: the server store layer owns the refusal.
|
|
108
324
|
function refreshEditorMetrics() {
|
|
109
325
|
const textarea = document.getElementById("editor-text");
|
|
110
326
|
if (!textarea) return;
|
|
327
|
+
autosizeTextareas();
|
|
111
328
|
const announcements = [];
|
|
112
329
|
|
|
113
330
|
const charCount = document.getElementById("char-count");
|
|
@@ -136,7 +353,9 @@ import { initTables } from "./ui.js";
|
|
|
136
353
|
state.textContent = present ? "Present" : "Missing";
|
|
137
354
|
state.classList.toggle("is-present", present);
|
|
138
355
|
if (present !== wasPresent) {
|
|
139
|
-
|
|
356
|
+
// The filed token may already carry its braces; the announcement shows one pair.
|
|
357
|
+
const bare = placeholder.replace(/^\{+|\}+$/g, "");
|
|
358
|
+
announcements.push(`Placeholder {${bare}} ${present ? "present" : "missing"}`);
|
|
140
359
|
}
|
|
141
360
|
}
|
|
142
361
|
}
|
|
@@ -149,21 +368,28 @@ import { initTables } from "./ui.js";
|
|
|
149
368
|
}
|
|
150
369
|
|
|
151
370
|
document.addEventListener("input", (event) => {
|
|
152
|
-
|
|
371
|
+
const target = event.target;
|
|
372
|
+
if (!(target instanceof HTMLTextAreaElement)) return;
|
|
373
|
+
if (target.id === "editor-text") {
|
|
153
374
|
refreshEditorMetrics();
|
|
375
|
+
} else if (target.matches("textarea[data-autosize]")) {
|
|
376
|
+
autosizeTextareas();
|
|
154
377
|
}
|
|
155
378
|
});
|
|
156
379
|
|
|
157
380
|
// A save swaps the editor region in; the textarea survives via hx-preserve, so one pass after
|
|
158
|
-
// the swap keeps the panel in sync with
|
|
381
|
+
// the swap keeps the panel and every autosized box in sync with what the server kept.
|
|
159
382
|
document.addEventListener("htmx:afterSwap", refreshEditorMetrics);
|
|
383
|
+
document.addEventListener("htmx:afterSwap", autosizeTextareas);
|
|
384
|
+
// The cap is a share of the current viewport, so a resize or rotation re-derives it.
|
|
385
|
+
addEventListener("resize", autosizeTextareas);
|
|
160
386
|
|
|
161
387
|
// "Use" on a revision loads its text into the editor and re-syncs the panel, ready to save.
|
|
162
388
|
document.addEventListener("click", (event) => {
|
|
163
389
|
const target = event.target instanceof Element ? event.target : null;
|
|
164
390
|
const useButton = target ? target.closest("[data-use-revision]") : null;
|
|
165
391
|
if (!useButton) return;
|
|
166
|
-
const textNode = useButton.closest(".
|
|
392
|
+
const textNode = useButton.closest(".timeline-item")?.querySelector(".timeline-body");
|
|
167
393
|
const textarea = document.getElementById("editor-text");
|
|
168
394
|
if (!textNode || !textarea) return;
|
|
169
395
|
textarea.value = textNode.textContent ?? "";
|
|
@@ -171,117 +397,36 @@ import { initTables } from "./ui.js";
|
|
|
171
397
|
textarea.focus();
|
|
172
398
|
});
|
|
173
399
|
|
|
174
|
-
// --- Batch
|
|
175
|
-
// The
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// and the native form still submits the checked rows.
|
|
180
|
-
const DRAFT_SELECTOR = 'input[type="checkbox"][name="id"]';
|
|
181
|
-
const SELECT_ALL_SELECTOR = 'input[type="checkbox"].select-all';
|
|
182
|
-
|
|
183
|
-
function selectionCount() {
|
|
184
|
-
return document.querySelectorAll(`#batch-form ${DRAFT_SELECTOR}:checked`).length;
|
|
185
|
-
}
|
|
186
|
-
|
|
400
|
+
// --- Batch selection count ---
|
|
401
|
+
// The boxes and the select-all state machine belong to ui.js's initTables (re-run after every
|
|
402
|
+
// swap); this page only renders the count onto the approve button. It ships enabled so the no-JS
|
|
403
|
+
// form can fire it; the boot call below disables it at first paint and each selection event
|
|
404
|
+
// re-syncs it, so an empty selection reads as dead with JS.
|
|
187
405
|
function updateApproveButton() {
|
|
188
406
|
const button = document.querySelector('#batch-form .batch-bar button[type="submit"]');
|
|
189
407
|
if (!button) return;
|
|
190
|
-
const count =
|
|
408
|
+
const count = document.querySelectorAll('#batch-form input[type="checkbox"][name="id"]:checked').length;
|
|
191
409
|
button.textContent = count === 0 ? "Approve selected" : `Approve ${count}`;
|
|
192
410
|
button.disabled = count === 0;
|
|
193
411
|
}
|
|
194
412
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (!batch) return;
|
|
198
|
-
const drafts = batch.querySelectorAll(DRAFT_SELECTOR);
|
|
199
|
-
const checked = batch.querySelectorAll(`${DRAFT_SELECTOR}:checked`).length;
|
|
200
|
-
selectAll.checked = drafts.length > 0 && checked === drafts.length;
|
|
201
|
-
selectAll.indeterminate = checked > 0 && checked < drafts.length;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function refreshSelection() {
|
|
205
|
-
for (const selectAll of document.querySelectorAll(SELECT_ALL_SELECTOR)) {
|
|
206
|
-
updateSelectAll(selectAll);
|
|
207
|
-
}
|
|
208
|
-
updateApproveButton();
|
|
209
|
-
}
|
|
413
|
+
document.addEventListener("table:selection", updateApproveButton);
|
|
414
|
+
document.addEventListener("htmx:afterSwap", updateApproveButton);
|
|
210
415
|
|
|
211
|
-
document.addEventListener("change", (event) => {
|
|
212
|
-
const target = event.target instanceof Element ? event.target : null;
|
|
213
|
-
if (!target) return;
|
|
214
|
-
if (target.matches(SELECT_ALL_SELECTOR)) {
|
|
215
|
-
const batch = target.closest(".batch");
|
|
216
|
-
if (batch) {
|
|
217
|
-
for (const draft of batch.querySelectorAll(DRAFT_SELECTOR)) {
|
|
218
|
-
draft.checked = target.checked;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
refreshSelection();
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
document.addEventListener("htmx:afterSwap", refreshSelection);
|
|
226
|
-
|
|
227
|
-
// --- Collapsible project sections and batches ---
|
|
228
|
-
// The server renders every section and batch open; JS collapses the ones this viewer collapsed
|
|
229
|
-
// before, from localStorage, and persists each toggle. Re-applied after every swap because a
|
|
230
|
-
// filter or action round replaces the whole list region. Without JS the toggles stay inert and
|
|
231
|
-
// everything stays open.
|
|
232
|
-
const COLLAPSE_STORAGE = "reviewzy.list-collapsed";
|
|
233
|
-
|
|
234
|
-
function readCollapsed() {
|
|
235
|
-
try {
|
|
236
|
-
const raw = localStorage.getItem(COLLAPSE_STORAGE);
|
|
237
|
-
const parsed = raw ? JSON.parse(raw) : {};
|
|
238
|
-
// A parsed array is not a key map: property sets on it would survive only until the next
|
|
239
|
-
// JSON.stringify, which drops them, so persistence would silently stop.
|
|
240
|
-
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
241
|
-
} catch {
|
|
242
|
-
return {};
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function persistCollapsed(key, collapsed) {
|
|
247
|
-
const map = readCollapsed();
|
|
248
|
-
if (collapsed) {
|
|
249
|
-
map[key] = true;
|
|
250
|
-
} else {
|
|
251
|
-
delete map[key];
|
|
252
|
-
}
|
|
253
|
-
try {
|
|
254
|
-
localStorage.setItem(COLLAPSE_STORAGE, JSON.stringify(map));
|
|
255
|
-
} catch {
|
|
256
|
-
// Storage can be unavailable (private windows); the toggle still works for the page.
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function applyCollapseState() {
|
|
261
|
-
const collapsed = readCollapsed();
|
|
262
|
-
for (const toggle of document.querySelectorAll("[data-collapse-toggle]")) {
|
|
263
|
-
const key = toggle.dataset.collapseKey ?? "";
|
|
264
|
-
const target = document.getElementById(toggle.dataset.collapseTarget ?? "");
|
|
265
|
-
if (!target) continue;
|
|
266
|
-
const hidden = collapsed[key] === true;
|
|
267
|
-
target.hidden = hidden;
|
|
268
|
-
toggle.setAttribute("aria-expanded", String(!hidden));
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
document.addEventListener("click", (event) => {
|
|
273
|
-
const target = event.target instanceof Element ? event.target : null;
|
|
274
|
-
const toggle = target ? target.closest("[data-collapse-toggle]") : null;
|
|
275
|
-
if (!toggle) return;
|
|
276
|
-
const el = document.getElementById(toggle.dataset.collapseTarget ?? "");
|
|
277
|
-
if (!el) return;
|
|
278
|
-
const hide = !el.hidden;
|
|
279
|
-
el.hidden = hide;
|
|
280
|
-
toggle.setAttribute("aria-expanded", String(!hide));
|
|
281
|
-
persistCollapsed(toggle.dataset.collapseKey ?? "", hide);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
applyCollapseState();
|
|
285
|
-
document.addEventListener("htmx:afterSwap", applyCollapseState);
|
|
286
416
|
document.addEventListener("htmx:afterSwap", initTables);
|
|
417
|
+
// The OOB swap replaces the status tab strip: the fresh row needs its ink placed.
|
|
418
|
+
document.addEventListener("htmx:afterSwap", initTabs);
|
|
419
|
+
// A swapped list brings fresh table wraps and textareas; the cue controller skips the ones it
|
|
420
|
+
// has already dressed.
|
|
421
|
+
document.addEventListener("htmx:afterSwap", initScrollFade);
|
|
422
|
+
// The list fragment replaces the page banner out of band; a banner the swap brought in needs
|
|
423
|
+
// its dismiss control wired, since ui.js's one sweep ran at boot.
|
|
424
|
+
document.addEventListener("htmx:afterSwap", initDismiss);
|
|
425
|
+
|
|
426
|
+
// First paint: the boxes take their content's height, the metrics row reads the prefill, and
|
|
427
|
+
// the batch button reflects the (empty) selection before the viewer touches anything.
|
|
428
|
+
autosizeTextareas();
|
|
429
|
+
refreshEditorMetrics();
|
|
430
|
+
updateApproveButton();
|
|
431
|
+
revealActiveTab();
|
|
287
432
|
})();
|