reviewzy 0.2.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 +7579 -600
- package/src/dashboard/static/cursor-rules.js +79 -0
- package/src/dashboard/static/cursor.css +134 -0
- package/src/dashboard/static/cursor.js +1749 -0
- package/src/dashboard/static/dashboard.css +281 -258
- package/src/dashboard/static/dashboard.js +331 -209
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/icons.svg +184 -0
- package/src/dashboard/static/tokens.css +607 -110
- package/src/dashboard/static/ui.js +2487 -0
- package/src/dashboard/views.tsx +1204 -950
- package/src/dashboard/word-diff.ts +18 -25
- package/src/db/migrations.ts +5 -6
- package/src/db/queries.ts +38 -1
- package/src/mcp/await-approved.ts +17 -10
- package/src/mcp/fetch-approved.ts +11 -6
- package/src/mcp/file-entries.ts +45 -22
- package/src/mcp/list-entries.ts +51 -55
- package/src/mcp/mark-applied.ts +5 -5
- package/src/mcp/server.ts +8 -3
|
@@ -1,76 +1,100 @@
|
|
|
1
|
-
// reviewzy dashboard behaviors.
|
|
1
|
+
// reviewzy dashboard behaviors. Loads as a module after htmx.min.js.
|
|
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";
|
|
8
|
+
|
|
2
9
|
(() => {
|
|
3
10
|
"use strict";
|
|
4
11
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
}
|
|
9
31
|
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (!active) {
|
|
14
|
-
navbarInk.style.opacity = "0";
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
const navRect = navbarNav.getBoundingClientRect();
|
|
18
|
-
const linkRect = active.getBoundingClientRect();
|
|
19
|
-
navbarInk.style.left = `${linkRect.left - navRect.left}px`;
|
|
20
|
-
navbarInk.style.width = `${linkRect.width}px`;
|
|
21
|
-
navbarInk.style.opacity = "1";
|
|
32
|
+
function raiseErrorToast(title, message, retry) {
|
|
33
|
+
const toast = showToast("danger", title, message);
|
|
34
|
+
if (toast) wireRetry(toast, retry);
|
|
22
35
|
}
|
|
23
36
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
requestAnimationFrame(positionNavbarInk);
|
|
32
|
-
|
|
33
|
-
// --- Filter errors ---
|
|
34
|
-
// A failed filter request (HTTP error or network error) replaces the list with the error box
|
|
35
|
-
// from the page's #error-box template; the retry button re-submits the filter form.
|
|
36
|
-
const form = document.getElementById("filters");
|
|
37
|
-
const list = document.getElementById("entries-list");
|
|
38
|
-
const errorTemplate = document.getElementById("error-box");
|
|
39
|
-
|
|
40
|
-
function showFilterError() {
|
|
41
|
-
if (!form || !list || !errorTemplate) return;
|
|
42
|
-
const box = errorTemplate.content.cloneNode(true);
|
|
43
|
-
const retry = box.querySelector("[data-retry]");
|
|
44
|
-
if (retry) {
|
|
45
|
-
retry.addEventListener("click", () => {
|
|
46
|
-
// htmx listens for submit directly on the form it processed, so a native requestSubmit
|
|
47
|
-
// reaches it; the reload fallback covers the case where htmx did not load.
|
|
48
|
-
if (typeof form.requestSubmit === "function") {
|
|
49
|
-
form.requestSubmit();
|
|
50
|
-
} else {
|
|
51
|
-
window.location.reload();
|
|
52
|
-
}
|
|
53
|
-
});
|
|
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();
|
|
54
44
|
}
|
|
55
|
-
list.replaceChildren(box);
|
|
56
45
|
}
|
|
57
46
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
});
|
|
61
88
|
}
|
|
62
89
|
|
|
63
90
|
// --- Filter auto-submit ---
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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.
|
|
69
96
|
function submitFilters() {
|
|
70
|
-
|
|
71
|
-
if (target && typeof target.requestSubmit === "function") {
|
|
72
|
-
target.requestSubmit();
|
|
73
|
-
}
|
|
97
|
+
submitForm(document.getElementById("filters"));
|
|
74
98
|
}
|
|
75
99
|
|
|
76
100
|
let searchTimer;
|
|
@@ -79,59 +103,228 @@
|
|
|
79
103
|
searchTimer = setTimeout(submitFilters, 300);
|
|
80
104
|
}
|
|
81
105
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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) {
|
|
85
135
|
const searchInput = document.getElementById("q");
|
|
86
|
-
statusSelect?.addEventListener("change", submitFilters);
|
|
87
|
-
projectSelect?.addEventListener("change", submitFilters);
|
|
88
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
|
+
});
|
|
89
148
|
}
|
|
90
149
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
});
|
|
97
165
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const target = document.getElementById("style-guide-form");
|
|
115
|
-
if (target && typeof target.requestSubmit === "function") {
|
|
116
|
-
target.requestSubmit();
|
|
117
|
-
} else {
|
|
118
|
-
window.location.reload();
|
|
119
|
-
}
|
|
120
|
-
});
|
|
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;
|
|
121
182
|
}
|
|
122
|
-
|
|
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");
|
|
192
|
+
}
|
|
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;
|
|
123
206
|
}
|
|
124
207
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|
|
127
319
|
|
|
128
320
|
// --- Editor live metrics ---
|
|
129
|
-
// The constraint
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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.
|
|
132
324
|
function refreshEditorMetrics() {
|
|
133
325
|
const textarea = document.getElementById("editor-text");
|
|
134
326
|
if (!textarea) return;
|
|
327
|
+
autosizeTextareas();
|
|
135
328
|
const announcements = [];
|
|
136
329
|
|
|
137
330
|
const charCount = document.getElementById("char-count");
|
|
@@ -160,7 +353,9 @@
|
|
|
160
353
|
state.textContent = present ? "Present" : "Missing";
|
|
161
354
|
state.classList.toggle("is-present", present);
|
|
162
355
|
if (present !== wasPresent) {
|
|
163
|
-
|
|
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"}`);
|
|
164
359
|
}
|
|
165
360
|
}
|
|
166
361
|
}
|
|
@@ -173,21 +368,28 @@
|
|
|
173
368
|
}
|
|
174
369
|
|
|
175
370
|
document.addEventListener("input", (event) => {
|
|
176
|
-
|
|
371
|
+
const target = event.target;
|
|
372
|
+
if (!(target instanceof HTMLTextAreaElement)) return;
|
|
373
|
+
if (target.id === "editor-text") {
|
|
177
374
|
refreshEditorMetrics();
|
|
375
|
+
} else if (target.matches("textarea[data-autosize]")) {
|
|
376
|
+
autosizeTextareas();
|
|
178
377
|
}
|
|
179
378
|
});
|
|
180
379
|
|
|
181
380
|
// A save swaps the editor region in; the textarea survives via hx-preserve, so one pass after
|
|
182
|
-
// 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.
|
|
183
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);
|
|
184
386
|
|
|
185
387
|
// "Use" on a revision loads its text into the editor and re-syncs the panel, ready to save.
|
|
186
388
|
document.addEventListener("click", (event) => {
|
|
187
389
|
const target = event.target instanceof Element ? event.target : null;
|
|
188
390
|
const useButton = target ? target.closest("[data-use-revision]") : null;
|
|
189
391
|
if (!useButton) return;
|
|
190
|
-
const textNode = useButton.closest(".
|
|
392
|
+
const textNode = useButton.closest(".timeline-item")?.querySelector(".timeline-body");
|
|
191
393
|
const textarea = document.getElementById("editor-text");
|
|
192
394
|
if (!textNode || !textarea) return;
|
|
193
395
|
textarea.value = textNode.textContent ?? "";
|
|
@@ -195,116 +397,36 @@
|
|
|
195
397
|
textarea.focus();
|
|
196
398
|
});
|
|
197
399
|
|
|
198
|
-
// --- Batch
|
|
199
|
-
// The
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
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
|
-
|
|
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.
|
|
211
405
|
function updateApproveButton() {
|
|
212
406
|
const button = document.querySelector('#batch-form .batch-bar button[type="submit"]');
|
|
213
407
|
if (!button) return;
|
|
214
|
-
const count =
|
|
408
|
+
const count = document.querySelectorAll('#batch-form input[type="checkbox"][name="id"]:checked').length;
|
|
215
409
|
button.textContent = count === 0 ? "Approve selected" : `Approve ${count}`;
|
|
216
410
|
button.disabled = count === 0;
|
|
217
411
|
}
|
|
218
412
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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);
|
|
413
|
+
document.addEventListener("table:selection", updateApproveButton);
|
|
414
|
+
document.addEventListener("htmx:afterSwap", updateApproveButton);
|
|
415
|
+
|
|
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();
|
|
310
432
|
})();
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|