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
package/src/dashboard/views.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/** @jsxImportSource hono/jsx */
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { raw } from "hono/html";
|
|
4
|
+
import type { Child } from "hono/jsx";
|
|
4
5
|
import type { JSX } from "hono/jsx/jsx-runtime";
|
|
5
6
|
import { listRevisions, parseConstraints, parseEntryImages } from "../db/human-save.ts";
|
|
6
7
|
import type { Constraints, HumanSaveRefusal, RevisionRow, TransitionRefusal } from "../db/human-save.ts";
|
|
7
8
|
import {
|
|
8
9
|
adjacentEntryIds,
|
|
9
10
|
countEntries,
|
|
11
|
+
countEntriesByStatus,
|
|
10
12
|
listEntries,
|
|
11
13
|
listProjects,
|
|
12
14
|
projectIdBySlug,
|
|
@@ -17,6 +19,7 @@ import type { StyleGuideForm, StyleGuideFormRefusal, StyleGuideInput } from "../
|
|
|
17
19
|
import type { Store } from "../db/store.ts";
|
|
18
20
|
import { parseRepoLink, REPO_ICON } from "./repo-link.ts";
|
|
19
21
|
import { diffWords } from "./word-diff.ts";
|
|
22
|
+
import type { WordDiffToken } from "./word-diff.ts";
|
|
20
23
|
import { NAME } from "../version.ts";
|
|
21
24
|
|
|
22
25
|
/**
|
|
@@ -35,7 +38,7 @@ const PAGE_SIZE = 200;
|
|
|
35
38
|
|
|
36
39
|
const STATUSES: readonly EntryStatus[] = ["draft", "approved", "applied", "rejected"];
|
|
37
40
|
|
|
38
|
-
/** The default
|
|
41
|
+
/** The default Open view: the two statuses that still need a human. */
|
|
39
42
|
const ACTIVE_STATUSES: readonly EntryStatus[] = ["draft", "approved"];
|
|
40
43
|
|
|
41
44
|
function isEntryStatus(value: string): value is EntryStatus {
|
|
@@ -56,11 +59,20 @@ const STATUS_LABEL: Record<EntryStatus, string> = {
|
|
|
56
59
|
rejected: "Rejected",
|
|
57
60
|
};
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
/** The timeline dot variant per revision status, matching the tag's own semantic. */
|
|
63
|
+
const STATUS_DOT: Record<EntryStatus, string> = {
|
|
64
|
+
draft: "timeline-dot-warning",
|
|
65
|
+
approved: "timeline-dot-success",
|
|
66
|
+
applied: "timeline-dot-accent",
|
|
67
|
+
rejected: "timeline-dot-danger",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type ProjectGroup = { slug: string; entries: EntryRow[] };
|
|
61
71
|
|
|
62
72
|
export type ListViewModel = {
|
|
63
73
|
readonly total: number;
|
|
74
|
+
/** The whole store's counts per status; the status tabs read these, never the filtered rows. */
|
|
75
|
+
readonly counts: Record<EntryStatus, number>;
|
|
64
76
|
readonly shown: number;
|
|
65
77
|
readonly groups: readonly ProjectGroup[];
|
|
66
78
|
readonly projects: readonly { id: string; slug: string }[];
|
|
@@ -69,7 +81,7 @@ export type ListViewModel = {
|
|
|
69
81
|
readonly status: string;
|
|
70
82
|
readonly project: string;
|
|
71
83
|
readonly filtersActive: boolean;
|
|
72
|
-
/** Whether the default
|
|
84
|
+
/** Whether the default Open view is what hides every match: a filter found rows, but all of them are applied or rejected. */
|
|
73
85
|
readonly hiddenByActiveView: boolean;
|
|
74
86
|
};
|
|
75
87
|
|
|
@@ -96,63 +108,19 @@ function allEntries(
|
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
/**
|
|
99
|
-
* Groups the filtered rows by project
|
|
100
|
-
* list query's ascending id order
|
|
101
|
-
* holding a draft or approved entry is actionable and leads, terminal-only batches (applied and
|
|
102
|
-
* rejected) follow. Each partition sorts newest batch first, with a deterministic order for
|
|
103
|
-
* batches minted in the same millisecond, so the same filter always renders the same page.
|
|
111
|
+
* Groups the filtered rows by project, projects in slug order. Within a project the rows keep
|
|
112
|
+
* the list query's ascending id order: ulids sort chronologically, so the oldest filing leads.
|
|
104
113
|
*/
|
|
105
114
|
function groupRows(rows: readonly EntryRow[], slugOf: Map<string, string>): ProjectGroup[] {
|
|
106
115
|
const byProject = new Map<string, EntryRow[]>();
|
|
107
116
|
for (const row of rows) {
|
|
108
117
|
byProject.set(row.project_id, [...(byProject.get(row.project_id) ?? []), row]);
|
|
109
118
|
}
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
byBatch.set(row.batch_id, [...(byBatch.get(row.batch_id) ?? []), row]);
|
|
116
|
-
}
|
|
117
|
-
const batches = [...byBatch.keys()].map((batchId) => ({ id: batchId, entries: byBatch.get(batchId)! }));
|
|
118
|
-
const actionable = batches.filter(isActionableBatch).sort(newestBatchFirst);
|
|
119
|
-
const terminal = batches.filter((batch) => !isActionableBatch(batch)).sort(newestBatchFirst);
|
|
120
|
-
return { slug: slugOf.get(projectId) ?? projectId, batches: [...actionable, ...terminal] };
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** A batch is actionable when any of its entries still needs a human: draft or approved. */
|
|
125
|
-
function isActionableBatch(batch: ProjectBatch): boolean {
|
|
126
|
-
return batch.entries.some((entry) => entry.status === "draft" || entry.status === "approved");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Code-unit descending comparison: ulids are fixed-length Crockford base32, so this equals time order. */
|
|
130
|
-
function compareDesc(a: string, b: string): number {
|
|
131
|
-
return a < b ? 1 : a > b ? -1 : 0;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** The newest `created_at` in a batch, walked: a batch can hold thousands of entries, and a spread would overflow the call stack. */
|
|
135
|
-
function maxCreatedAt(batch: ProjectBatch): number {
|
|
136
|
-
let max = 0;
|
|
137
|
-
for (const entry of batch.entries) {
|
|
138
|
-
if (entry.created_at > max) max = entry.created_at;
|
|
139
|
-
}
|
|
140
|
-
return max;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Newest batch first. Batch ulids share a millisecond timestamp prefix, so a descending id is
|
|
145
|
-
* newest-first; batches minted in the same millisecond tie on that prefix, and the batch's
|
|
146
|
-
* newest `created_at` breaks the tie before the id itself, so a same-ms burst still renders in
|
|
147
|
-
* one fixed order.
|
|
148
|
-
*/
|
|
149
|
-
function newestBatchFirst(a: ProjectBatch, b: ProjectBatch): number {
|
|
150
|
-
const timeCmp = compareDesc(a.id.slice(0, 10), b.id.slice(0, 10));
|
|
151
|
-
if (timeCmp !== 0) return timeCmp;
|
|
152
|
-
const aCreated = maxCreatedAt(a);
|
|
153
|
-
const bCreated = maxCreatedAt(b);
|
|
154
|
-
if (aCreated !== bCreated) return bCreated - aCreated;
|
|
155
|
-
return compareDesc(a.id, b.id);
|
|
119
|
+
const groups = [...byProject].map(([projectId, projectRows]) => ({
|
|
120
|
+
slug: slugOf.get(projectId) ?? projectId,
|
|
121
|
+
entries: projectRows,
|
|
122
|
+
}));
|
|
123
|
+
return groups.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
156
124
|
}
|
|
157
125
|
|
|
158
126
|
function loadList(store: Store, params: ListParams): ListViewModel {
|
|
@@ -161,8 +129,8 @@ function loadList(store: Store, params: ListParams): ListViewModel {
|
|
|
161
129
|
const statusRaw = params.status;
|
|
162
130
|
const statusActive = statusRaw !== undefined && statusRaw !== "";
|
|
163
131
|
// `all` is the explicit "every status" filter value; the default (no status param, or an empty
|
|
164
|
-
// one) is the
|
|
165
|
-
// partial header. Both query every status; the
|
|
132
|
+
// one) is the Open view of draft + approved only, which is not a filter and never renders the
|
|
133
|
+
// partial header. Both query every status; the Open view narrows the walked rows afterward.
|
|
166
134
|
const allStatuses = statusActive && statusRaw === "all";
|
|
167
135
|
const status = statusActive && !allStatuses && isEntryStatus(statusRaw) ? statusRaw : undefined;
|
|
168
136
|
const projectRaw = params.project;
|
|
@@ -178,6 +146,7 @@ function loadList(store: Store, params: ListParams): ListViewModel {
|
|
|
178
146
|
const slugOf = new Map(projects.map((project) => [project.id, project.slug]));
|
|
179
147
|
return {
|
|
180
148
|
total: countEntries(store),
|
|
149
|
+
counts: countEntriesByStatus(store),
|
|
181
150
|
shown: rows.length,
|
|
182
151
|
groups: groupRows(rows, slugOf),
|
|
183
152
|
projects,
|
|
@@ -207,52 +176,282 @@ function entryTitle(entry: EntryRow): string {
|
|
|
207
176
|
return fileBasename(entry.file);
|
|
208
177
|
}
|
|
209
178
|
|
|
210
|
-
/**
|
|
211
|
-
function
|
|
212
|
-
|
|
179
|
+
/** Whether the displayed title is the agent-supplied one, so the anchor text it stands in for is otherwise unread in the list. */
|
|
180
|
+
function hasOwnTitle(entry: EntryRow): boolean {
|
|
181
|
+
const title = entry.title?.trim();
|
|
182
|
+
return title !== undefined && title !== "";
|
|
213
183
|
}
|
|
214
184
|
|
|
215
|
-
/** The section the current page belongs to, marking the matching
|
|
216
|
-
type NavSection = "entries" | "style-guide"
|
|
185
|
+
/** The section the current page belongs to, marking the matching nav link. */
|
|
186
|
+
type NavSection = "entries" | "style-guide";
|
|
217
187
|
|
|
218
|
-
/** The icon sprite, pasted into every full page's body
|
|
188
|
+
/** The icon sprite, pasted into every full page's body, read off the vendored file so the two can never drift. */
|
|
219
189
|
const ICON_SPRITE = readFileSync(new URL("./static/icons.svg", import.meta.url), "utf8");
|
|
220
190
|
|
|
221
|
-
|
|
191
|
+
/** The theme toggle, once per shell: ui.js's initTheme wires it and owns the pressed state. */
|
|
192
|
+
function ThemeToggle() {
|
|
222
193
|
return (
|
|
223
|
-
<
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
194
|
+
<button class="btn btn-icon btn-sm" data-theme-toggle aria-pressed="false" aria-label="Switch to light theme">
|
|
195
|
+
<svg class="icon-sun" aria-hidden="true"><use href="#i-sun" /></svg>
|
|
196
|
+
<svg class="icon-moon" aria-hidden="true"><use href="#i-moon" /></svg>
|
|
197
|
+
</button>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The queue's draft count. The htmx list fragment re-renders it out of band, so an action round
|
|
203
|
+
* that moves a draft cannot leave the sidebar stale.
|
|
204
|
+
*/
|
|
205
|
+
function DraftBadge({ count, oob = false }: { count: number; oob?: boolean }) {
|
|
206
|
+
return (
|
|
207
|
+
<span class="nav-badge" id="entries-badge" hx-swap-oob={oob ? "true" : undefined}>
|
|
208
|
+
{count}
|
|
209
|
+
</span>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The app shell every full page shares: the mobile topbar and overlay outside the grid, then the
|
|
215
|
+
* sidebar and the page column. Under 768px the same `.sidebar` element is the drawer, which is
|
|
216
|
+
* why there is no second navigation tree and why ui.js's drawer and rail controllers both act on
|
|
217
|
+
* this one node.
|
|
218
|
+
*/
|
|
219
|
+
function Shell({
|
|
220
|
+
signedIn,
|
|
221
|
+
active,
|
|
222
|
+
draftCount,
|
|
223
|
+
children,
|
|
224
|
+
}: {
|
|
225
|
+
signedIn: boolean;
|
|
226
|
+
active: NavSection;
|
|
227
|
+
draftCount: number;
|
|
228
|
+
children: Child;
|
|
229
|
+
}) {
|
|
230
|
+
return (
|
|
231
|
+
<>
|
|
232
|
+
<div class="mobile-topbar">
|
|
233
|
+
<div class="logo-name">{NAME}</div>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
class="hamburger"
|
|
237
|
+
id="hamburger-btn"
|
|
238
|
+
aria-label="Open navigation"
|
|
239
|
+
aria-expanded="false"
|
|
240
|
+
aria-controls="sidebar"
|
|
241
|
+
>
|
|
242
|
+
<svg aria-hidden="true"><use href="#i-menu" /></svg>
|
|
243
|
+
</button>
|
|
237
244
|
</div>
|
|
238
|
-
|
|
245
|
+
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
|
246
|
+
<div class="layout">
|
|
247
|
+
<nav class="sidebar" id="sidebar" aria-label="Dashboard">
|
|
248
|
+
<div class="sidebar-logo">
|
|
249
|
+
<div><div class="logo-name">{NAME}</div></div>
|
|
250
|
+
<button
|
|
251
|
+
class="sidebar-collapse"
|
|
252
|
+
type="button"
|
|
253
|
+
aria-controls="sidebar"
|
|
254
|
+
aria-expanded="true"
|
|
255
|
+
aria-label="Collapse sidebar"
|
|
256
|
+
>
|
|
257
|
+
<svg aria-hidden="true"><use href="#i-chevron-left" /></svg>
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="sidebar-nav">
|
|
261
|
+
<div class="nav-group">
|
|
262
|
+
<div class="nav-group-label">Queue</div>
|
|
263
|
+
<a
|
|
264
|
+
class={`nav-item${active === "entries" ? " active" : ""}`}
|
|
265
|
+
href="/"
|
|
266
|
+
aria-current={active === "entries" ? "true" : undefined}
|
|
267
|
+
>
|
|
268
|
+
<span>Entries</span>
|
|
269
|
+
<DraftBadge count={draftCount} />
|
|
270
|
+
</a>
|
|
271
|
+
</div>
|
|
272
|
+
<div class="nav-group">
|
|
273
|
+
<div class="nav-group-label">Content</div>
|
|
274
|
+
<a
|
|
275
|
+
class={`nav-item${active === "style-guide" ? " active" : ""}`}
|
|
276
|
+
href="/style-guide"
|
|
277
|
+
aria-current={active === "style-guide" ? "true" : undefined}
|
|
278
|
+
>
|
|
279
|
+
<span>Style guide</span>
|
|
280
|
+
</a>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="sidebar-user">
|
|
284
|
+
<ThemeToggle />
|
|
285
|
+
{signedIn ? (
|
|
286
|
+
<form method="post" action="/logout">
|
|
287
|
+
<button class="btn btn-secondary btn-sm" type="submit">Sign out</button>
|
|
288
|
+
</form>
|
|
289
|
+
) : null}
|
|
290
|
+
</div>
|
|
291
|
+
</nav>
|
|
292
|
+
<main class="page" id="main">{children}</main>
|
|
293
|
+
</div>
|
|
294
|
+
</>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The queue's header meta: the actionable count, the store total, and the project count. The
|
|
300
|
+
* htmx list fragment re-renders it out of band, so an action round moves the numbers with it.
|
|
301
|
+
*/
|
|
302
|
+
function PageMeta({
|
|
303
|
+
draftCount,
|
|
304
|
+
total,
|
|
305
|
+
projects,
|
|
306
|
+
oob = false,
|
|
307
|
+
}: {
|
|
308
|
+
draftCount: number;
|
|
309
|
+
total: number;
|
|
310
|
+
projects: number;
|
|
311
|
+
oob?: boolean;
|
|
312
|
+
}) {
|
|
313
|
+
return (
|
|
314
|
+
<div class="page-meta" id="page-meta" hx-swap-oob={oob ? "true" : undefined}>
|
|
315
|
+
<span class="page-meta-item">
|
|
316
|
+
<span class="num">{draftCount}</span> {draftCount === 1 ? "draft waiting" : "drafts waiting"}
|
|
317
|
+
</span>
|
|
318
|
+
<span class="page-meta-item">
|
|
319
|
+
<span class="num">{total}</span> {total === 1 ? "entry" : "entries"}
|
|
320
|
+
</span>
|
|
321
|
+
<span class="page-meta-item">
|
|
322
|
+
<span class="num">{projects}</span> {projects === 1 ? "project" : "projects"}
|
|
323
|
+
</span>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The one status control: an underline tab strip of submit buttons inside the filters form, so a
|
|
330
|
+
* click submits the form with its `status` value with or without JS. The Open tab is the default
|
|
331
|
+
* view (draft + approved) and carries no filter value; the badge counts come off the store's one
|
|
332
|
+
* grouped read, never the filtered rows. In the htmx fragment the strip rides an out-of-band swap,
|
|
333
|
+
* since the list region is the only main target.
|
|
334
|
+
*/
|
|
335
|
+
function StatusTabs({ counts, status, oob = false }: { counts: Record<EntryStatus, number>; status: string; oob?: boolean }) {
|
|
336
|
+
const open = counts.draft + counts.approved;
|
|
337
|
+
return (
|
|
338
|
+
<div class="tabs-wrap status-tabs" id="status-tabs" hx-swap-oob={oob ? "true" : undefined}>
|
|
339
|
+
<div class="tabs" role="group" aria-label="Status">
|
|
340
|
+
<button
|
|
341
|
+
class={`tab${status === "" ? " active" : ""}`}
|
|
342
|
+
type="submit"
|
|
343
|
+
name="status"
|
|
344
|
+
value=""
|
|
345
|
+
aria-current={status === "" ? "true" : undefined}
|
|
346
|
+
>
|
|
347
|
+
<span>Open</span><span class="tab-badge num">{open}</span>
|
|
348
|
+
</button>
|
|
349
|
+
{STATUSES.map((s) => (
|
|
350
|
+
<button
|
|
351
|
+
class={`tab${status === s ? " active" : ""}`}
|
|
352
|
+
type="submit"
|
|
353
|
+
name="status"
|
|
354
|
+
value={s}
|
|
355
|
+
aria-current={status === s ? "true" : undefined}
|
|
356
|
+
>
|
|
357
|
+
<span>{STATUS_LABEL[s]}</span><span class="tab-badge num">{counts[s]}</span>
|
|
358
|
+
</button>
|
|
359
|
+
))}
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The toolbar's count line: the visible rows over the store total, right-aligned on the filter
|
|
367
|
+
* row, plus the way back to the default view while a filter is active. The htmx fragment
|
|
368
|
+
* re-renders it out of band with every action round.
|
|
369
|
+
*/
|
|
370
|
+
function ListMeta({ vm, oob = false }: { vm: ListViewModel; oob?: boolean }) {
|
|
371
|
+
return (
|
|
372
|
+
<div id="list-meta" class="list-meta" hx-swap-oob={oob ? "true" : undefined}>
|
|
373
|
+
<span class="pagination-count num">
|
|
374
|
+
{vm.shown} of {vm.total} {vm.total === 1 ? "entry" : "entries"}
|
|
375
|
+
</span>
|
|
376
|
+
{vm.filtersActive ? <a class="btn btn-secondary btn-sm" href="/">Clear filters</a> : null}
|
|
377
|
+
</div>
|
|
239
378
|
);
|
|
240
379
|
}
|
|
241
380
|
|
|
242
|
-
function PageHeader({
|
|
381
|
+
function PageHeader({ vm }: { vm: ListViewModel }) {
|
|
243
382
|
return (
|
|
244
383
|
<header class="page-header">
|
|
245
384
|
<div class="page-header-body">
|
|
246
385
|
<h1 class="page-title">Entries</h1>
|
|
247
|
-
<
|
|
248
|
-
<span class="page-meta-item">{total} {total === 1 ? "entry" : "entries"}</span>
|
|
249
|
-
</div>
|
|
386
|
+
<PageMeta draftCount={vm.counts.draft} total={vm.total} projects={vm.projects.length} />
|
|
250
387
|
</div>
|
|
251
388
|
</header>
|
|
252
389
|
);
|
|
253
390
|
}
|
|
254
391
|
|
|
392
|
+
/**
|
|
393
|
+
* One dropdown filter as the system `.select`: the trigger shows the current label, the menu
|
|
394
|
+
* carries one option per value, and a hidden input carries the submission. The hidden input ships
|
|
395
|
+
* disabled with its name already on it, so a no-JS submit reads the `<noscript>` native selects
|
|
396
|
+
* instead of a stale hidden value; dashboard.js enables the hidden ones once the widgets are live.
|
|
397
|
+
*/
|
|
398
|
+
function FilterSelect({
|
|
399
|
+
id,
|
|
400
|
+
label,
|
|
401
|
+
field,
|
|
402
|
+
current,
|
|
403
|
+
options,
|
|
404
|
+
}: {
|
|
405
|
+
id: string;
|
|
406
|
+
label: string;
|
|
407
|
+
field: string;
|
|
408
|
+
current: string;
|
|
409
|
+
options: readonly { value: string; label: string }[];
|
|
410
|
+
}) {
|
|
411
|
+
const currentLabel = options.find((option) => option.value === current)?.label ?? options[0]!.label;
|
|
412
|
+
return (
|
|
413
|
+
<>
|
|
414
|
+
<span class="sr-only" id={`${id}-label`}>{label}</span>
|
|
415
|
+
<div class="select" data-filter={field}>
|
|
416
|
+
<button
|
|
417
|
+
type="button"
|
|
418
|
+
class="input select-trigger"
|
|
419
|
+
role="combobox"
|
|
420
|
+
aria-expanded="false"
|
|
421
|
+
aria-haspopup="listbox"
|
|
422
|
+
aria-labelledby={`${id}-label`}
|
|
423
|
+
>
|
|
424
|
+
<span class="select-value">{currentLabel}</span>
|
|
425
|
+
<svg class="select-caret" aria-hidden="true"><use href="#i-chevron-down" /></svg>
|
|
426
|
+
</button>
|
|
427
|
+
<div class="select-menu" role="listbox" aria-labelledby={`${id}-label`}>
|
|
428
|
+
{options.map((option) => (
|
|
429
|
+
<div
|
|
430
|
+
class="menu-item"
|
|
431
|
+
role="option"
|
|
432
|
+
aria-selected={String(option.value === current)}
|
|
433
|
+
data-value={option.value}
|
|
434
|
+
>
|
|
435
|
+
{option.label}
|
|
436
|
+
</div>
|
|
437
|
+
))}
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
<input type="hidden" name={field} value={current} data-filter-field={field} disabled />
|
|
441
|
+
</>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The queue's controls: the status tab strip above the search and project row, and the count line
|
|
447
|
+
* on the row's end edge. The tabs submit the same form, so one GET request carries whatever the
|
|
448
|
+
* clicker set.
|
|
449
|
+
*/
|
|
255
450
|
function FilterForm({ vm }: { vm: ListViewModel }) {
|
|
451
|
+
const projectOptions = [
|
|
452
|
+
{ value: "", label: "All projects" },
|
|
453
|
+
...vm.projects.map((project) => ({ value: project.slug, label: project.slug })),
|
|
454
|
+
];
|
|
256
455
|
return (
|
|
257
456
|
<form
|
|
258
457
|
id="filters"
|
|
@@ -262,83 +461,110 @@ function FilterForm({ vm }: { vm: ListViewModel }) {
|
|
|
262
461
|
hx-target="#entries-list"
|
|
263
462
|
hx-swap="innerHTML"
|
|
264
463
|
hx-indicator="#entries-loading"
|
|
265
|
-
class="filters"
|
|
266
464
|
>
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
<label class="field-label" for="status">Status</label>
|
|
280
|
-
<select class="input" id="status" name="status">
|
|
281
|
-
<option value="" selected={vm.status === ""}>Active</option>
|
|
282
|
-
<option value="all" selected={vm.status === "all"}>All statuses</option>
|
|
283
|
-
<option value="draft" selected={vm.status === "draft"}>Draft</option>
|
|
284
|
-
<option value="approved" selected={vm.status === "approved"}>Approved</option>
|
|
285
|
-
<option value="applied" selected={vm.status === "applied"}>Applied</option>
|
|
286
|
-
<option value="rejected" selected={vm.status === "rejected"}>Rejected</option>
|
|
287
|
-
</select>
|
|
465
|
+
{/* The form's first submit button is the default for an implicit submit (Enter in the
|
|
466
|
+
search field without JS): it carries the current status, so Enter never resets the
|
|
467
|
+
filter to Open. Visually hidden and out of the tab order; dashboard.js drops the name
|
|
468
|
+
once the widget path owns the status, so only the hidden field ever submits. */}
|
|
469
|
+
<button class="sr-only" type="submit" name="status" value={vm.status} tabindex={-1} aria-hidden="true">
|
|
470
|
+
Apply filters
|
|
471
|
+
</button>
|
|
472
|
+
{/* The strip's own attributes are the markup contract, so the named scroller that carries
|
|
473
|
+
the five tabs on a narrow screen is this wrapper: it keeps the scroll position when an
|
|
474
|
+
action round swaps the strip inside it. */}
|
|
475
|
+
<div class="status-scroll" data-scroll-fade>
|
|
476
|
+
<StatusTabs counts={vm.counts} status={vm.status} />
|
|
288
477
|
</div>
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
478
|
+
{/* The selected status the other fields submit under: the tab buttons carry it on a click,
|
|
479
|
+
and dashboard.js mirrors it here so a search or project change cannot silently drop back
|
|
480
|
+
to the Open view. Ships disabled so a no-JS submit reads the clicked tab, never a stale
|
|
481
|
+
hidden value. */}
|
|
482
|
+
<input type="hidden" name="status" value={vm.status} data-filter-field="status" disabled />
|
|
483
|
+
<div class="filter-bar">
|
|
484
|
+
<div class="input-wrap has-icon filter-search">
|
|
485
|
+
<div class="input-icon">
|
|
486
|
+
<svg aria-hidden="true"><use href="#i-search" /></svg>
|
|
487
|
+
</div>
|
|
488
|
+
<label class="sr-only" for="q">Search</label>
|
|
489
|
+
<input
|
|
490
|
+
class="input"
|
|
491
|
+
id="q"
|
|
492
|
+
name="q"
|
|
493
|
+
type="search"
|
|
494
|
+
placeholder="Search file, anchor, or text"
|
|
495
|
+
value={vm.q}
|
|
496
|
+
/>
|
|
497
|
+
</div>
|
|
498
|
+
<FilterSelect id="filter-project" label="Project" field="project" current={vm.project} options={projectOptions} />
|
|
499
|
+
{/* The no-JS submit path: a native select carrying the name the hidden input holds, so the
|
|
500
|
+
filters still work with scripting off. With JS on, this block is not in the DOM. */}
|
|
501
|
+
<noscript>
|
|
502
|
+
<label class="sr-only" for="project-native">Project</label>
|
|
503
|
+
<select class="input" id="project-native" name="project">
|
|
504
|
+
{projectOptions.map((option) => (
|
|
505
|
+
<option value={option.value} selected={option.value === vm.project}>{option.label}</option>
|
|
506
|
+
))}
|
|
507
|
+
</select>
|
|
508
|
+
{/* The explicit submit carries the current status too, since the hidden field above
|
|
509
|
+
ships disabled without JS. */}
|
|
510
|
+
<button class="btn btn-primary" type="submit" name="status" value={vm.status}>Apply filters</button>
|
|
511
|
+
</noscript>
|
|
512
|
+
<ListMeta vm={vm} />
|
|
299
513
|
</div>
|
|
300
|
-
<noscript>
|
|
301
|
-
<button class="btn btn-primary" type="submit">Apply filters</button>
|
|
302
|
-
</noscript>
|
|
303
514
|
</form>
|
|
304
515
|
);
|
|
305
516
|
}
|
|
306
517
|
|
|
307
518
|
/**
|
|
308
|
-
* What an action round settles into, rendered
|
|
309
|
-
* could not move, why, and the fix) or the batch report (how many moved, each refusal
|
|
310
|
-
* The route layer builds these from the store's outcomes; the view owns the shapes.
|
|
519
|
+
* What an action round settles into, rendered as the page's one banner: a single refusal (one
|
|
520
|
+
* entry could not move, why, and the fix) or the batch report (how many moved, each refusal
|
|
521
|
+
* named). The route layer builds these from the store's outcomes; the view owns the shapes.
|
|
311
522
|
*/
|
|
312
523
|
export type ListNotice =
|
|
313
524
|
| { readonly kind: "refusal"; readonly title: string; readonly message: string }
|
|
314
525
|
| { readonly kind: "batch"; readonly approved: number; readonly total: number; readonly refused: readonly string[] };
|
|
315
526
|
|
|
316
|
-
/** The
|
|
317
|
-
function
|
|
527
|
+
/** The banner's one dismiss control; ui.js's initDismiss removes the banner it belongs to. */
|
|
528
|
+
function BannerDismiss() {
|
|
529
|
+
return (
|
|
530
|
+
<button type="button" class="close" aria-label="Dismiss">
|
|
531
|
+
<svg aria-hidden="true"><use href="#i-x" /></svg>
|
|
532
|
+
</button>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** An action round's settled outcome as the page's one banner: a success panel when a batch fully moved, an alert otherwise. */
|
|
537
|
+
function NoticeBanner({ notice }: { notice: ListNotice }) {
|
|
318
538
|
if (notice.kind === "refusal") {
|
|
319
|
-
return
|
|
539
|
+
return (
|
|
540
|
+
<div class="banner banner-danger" role="alert" tabindex={-1}>
|
|
541
|
+
<span class="banner-icon"><svg aria-hidden="true"><use href="#i-error" /></svg></span>
|
|
542
|
+
<div class="banner-body">
|
|
543
|
+
<div class="banner-title">{notice.title}</div>
|
|
544
|
+
<div class="banner-text">{notice.message}</div>
|
|
545
|
+
</div>
|
|
546
|
+
<BannerDismiss />
|
|
547
|
+
</div>
|
|
548
|
+
);
|
|
320
549
|
}
|
|
321
550
|
if (notice.refused.length === 0) {
|
|
322
551
|
return (
|
|
323
|
-
<div class="
|
|
324
|
-
<
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
<div class="callout-content">
|
|
328
|
-
<div class="callout-title">
|
|
552
|
+
<div class="banner banner-success" role="status" tabindex={-1}>
|
|
553
|
+
<span class="banner-icon"><svg aria-hidden="true"><use href="#i-success" /></svg></span>
|
|
554
|
+
<div class="banner-body">
|
|
555
|
+
<div class="banner-title">
|
|
329
556
|
Approved {notice.approved} {notice.approved === 1 ? "entry" : "entries"}
|
|
330
557
|
</div>
|
|
331
558
|
</div>
|
|
559
|
+
<BannerDismiss />
|
|
332
560
|
</div>
|
|
333
561
|
);
|
|
334
562
|
}
|
|
335
563
|
return (
|
|
336
|
-
<div class="
|
|
337
|
-
<
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
<div class="callout-content">
|
|
341
|
-
<div class="callout-title">
|
|
564
|
+
<div class="banner banner-danger" role="alert" tabindex={-1}>
|
|
565
|
+
<span class="banner-icon"><svg aria-hidden="true"><use href="#i-error" /></svg></span>
|
|
566
|
+
<div class="banner-body">
|
|
567
|
+
<div class="banner-title">
|
|
342
568
|
Approved {notice.approved} of {notice.total} entries
|
|
343
569
|
</div>
|
|
344
570
|
<ul class="notice-list">
|
|
@@ -347,58 +573,46 @@ function NoticeCallout({ notice }: { notice: ListNotice }) {
|
|
|
347
573
|
))}
|
|
348
574
|
</ul>
|
|
349
575
|
</div>
|
|
576
|
+
<BannerDismiss />
|
|
577
|
+
</div>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** The page's one banner slot; the htmx fragment replaces it out of band so a round can set or clear it. */
|
|
582
|
+
function PageBanner({ notice, oob = false }: { notice: ListNotice | undefined; oob?: boolean }) {
|
|
583
|
+
return (
|
|
584
|
+
<div id="page-banner" hx-swap-oob={oob ? "true" : undefined}>
|
|
585
|
+
{notice !== undefined ? <NoticeBanner notice={notice} /> : null}
|
|
350
586
|
</div>
|
|
351
587
|
);
|
|
352
588
|
}
|
|
353
589
|
|
|
354
|
-
/** The settled list region:
|
|
355
|
-
function ListRegion({ vm
|
|
590
|
+
/** The settled list region: the tables, or one of the three empty states. The loading and error states are client-side. */
|
|
591
|
+
function ListRegion({ vm }: { vm: ListViewModel }) {
|
|
356
592
|
if (vm.shown === 0) {
|
|
357
593
|
// Zero entries at all is "no entries yet" even under a filter: nothing exists to filter, and
|
|
358
594
|
// the empty state explains how entries get here. A store with entries but no matches is either
|
|
359
|
-
// a filter miss or the default
|
|
360
|
-
return (
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
/>
|
|
373
|
-
) : (
|
|
374
|
-
<EmptyActive />
|
|
375
|
-
)}
|
|
376
|
-
</>
|
|
595
|
+
// a filter miss or the default Open view over a store full of applied and rejected entries.
|
|
596
|
+
return vm.total === 0 ? (
|
|
597
|
+
<EmptyAll />
|
|
598
|
+
) : vm.filtersActive ? (
|
|
599
|
+
<EmptyMatch
|
|
600
|
+
revealAllHref={
|
|
601
|
+
vm.hiddenByActiveView
|
|
602
|
+
? `/?q=${encodeURIComponent(vm.q)}&project=${encodeURIComponent(vm.project)}&status=all`
|
|
603
|
+
: undefined
|
|
604
|
+
}
|
|
605
|
+
/>
|
|
606
|
+
) : (
|
|
607
|
+
<EmptyActive />
|
|
377
608
|
);
|
|
378
609
|
}
|
|
379
|
-
return
|
|
380
|
-
<>
|
|
381
|
-
{notice !== undefined ? <NoticeCallout notice={notice} /> : null}
|
|
382
|
-
{vm.filtersActive && vm.shown < vm.total ? <PartialHeader shown={vm.shown} total={vm.total} /> : null}
|
|
383
|
-
<BatchForm vm={vm} />
|
|
384
|
-
</>
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function PartialHeader({ shown, total }: { shown: number; total: number }) {
|
|
389
|
-
return (
|
|
390
|
-
<div class="partial-header">
|
|
391
|
-
<span class="partial-count">
|
|
392
|
-
Showing {shown} of {total} entries
|
|
393
|
-
</span>
|
|
394
|
-
<a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
|
|
395
|
-
</div>
|
|
396
|
-
);
|
|
610
|
+
return <BatchForm vm={vm} />;
|
|
397
611
|
}
|
|
398
612
|
|
|
399
613
|
function EmptyAll() {
|
|
400
614
|
return (
|
|
401
|
-
<div class="
|
|
615
|
+
<div class="empty-state">
|
|
402
616
|
<svg aria-hidden="true"><use href="#i-file" /></svg>
|
|
403
617
|
<div class="empty-title">No entries yet</div>
|
|
404
618
|
<div>Agents file text through the reviewzy mcp endpoint. Filed entries appear here.</div>
|
|
@@ -407,14 +621,14 @@ function EmptyAll() {
|
|
|
407
621
|
}
|
|
408
622
|
|
|
409
623
|
/**
|
|
410
|
-
* The filter-miss state, with a way onward. When the default
|
|
624
|
+
* The filter-miss state, with a way onward. When the default Open view is what hides the
|
|
411
625
|
* matches (a search that only hits applied or rejected rows), the way onward is the all-statuses
|
|
412
|
-
* view under the same filters — clearing the filters alone would land back on the
|
|
626
|
+
* view under the same filters — clearing the filters alone would land back on the Open view
|
|
413
627
|
* and still show nothing.
|
|
414
628
|
*/
|
|
415
629
|
function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
|
|
416
630
|
return (
|
|
417
|
-
<div class="
|
|
631
|
+
<div class="empty-state">
|
|
418
632
|
<svg aria-hidden="true"><use href="#i-search" /></svg>
|
|
419
633
|
<div class="empty-title">No entries match</div>
|
|
420
634
|
<div>Try different search terms, or clear the filters.</div>
|
|
@@ -429,10 +643,10 @@ function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
|
|
|
429
643
|
);
|
|
430
644
|
}
|
|
431
645
|
|
|
432
|
-
/** The default
|
|
646
|
+
/** The default Open view over a store whose entries are all applied or rejected: nothing to do, no filters to clear. */
|
|
433
647
|
function EmptyActive() {
|
|
434
648
|
return (
|
|
435
|
-
<div class="
|
|
649
|
+
<div class="empty-state">
|
|
436
650
|
<svg aria-hidden="true"><use href="#i-success" /></svg>
|
|
437
651
|
<div class="empty-title">Nothing to review</div>
|
|
438
652
|
<div>Every filed entry is applied or rejected. New drafts appear here as agents file them.</div>
|
|
@@ -441,11 +655,12 @@ function EmptyActive() {
|
|
|
441
655
|
}
|
|
442
656
|
|
|
443
657
|
/**
|
|
444
|
-
* The batch approve form wrapping
|
|
658
|
+
* The batch approve form wrapping every project table: the checkboxes post as `id`, the hidden
|
|
445
659
|
* inputs carry the active filters so an action round re-renders the region under the same filter
|
|
446
660
|
* (htmx includes the closest form's inputs for the row buttons too), and the native action keeps
|
|
447
661
|
* no-JS batch approve working. One form per page, never nested: the filters form lives outside the
|
|
448
|
-
* swap region.
|
|
662
|
+
* swap region. The submit ships ENABLED so the no-JS path can fire it (an empty selection answers
|
|
663
|
+
* its refusal); dashboard.js disables it at first paint and enables it on a checked box.
|
|
449
664
|
*/
|
|
450
665
|
function BatchForm({ vm }: { vm: ListViewModel }) {
|
|
451
666
|
return (
|
|
@@ -461,123 +676,92 @@ function BatchForm({ vm }: { vm: ListViewModel }) {
|
|
|
461
676
|
<input type="hidden" name="q" value={vm.q} />
|
|
462
677
|
<input type="hidden" name="status" value={vm.status} />
|
|
463
678
|
<input type="hidden" name="project" value={vm.project} />
|
|
464
|
-
<div class="batch-bar">
|
|
465
|
-
<button class="btn btn-
|
|
679
|
+
<div class="toolbar batch-bar section-sticky">
|
|
680
|
+
<button class="btn btn-fill btn-sm" type="submit" hx-disabled-elt="this">
|
|
466
681
|
Approve selected
|
|
467
682
|
</button>
|
|
468
683
|
</div>
|
|
469
684
|
{vm.groups.map((group) => (
|
|
470
|
-
<
|
|
685
|
+
<ProjectTable key={group.slug} group={group} />
|
|
471
686
|
))}
|
|
472
687
|
</form>
|
|
473
688
|
);
|
|
474
689
|
}
|
|
475
690
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
collapsed before (localStorage) and keeps the buttons inert without JS. */}
|
|
487
|
-
<button
|
|
488
|
-
type="button"
|
|
489
|
-
class="btn btn-icon collapse-toggle"
|
|
490
|
-
aria-expanded="true"
|
|
491
|
-
aria-controls={`project-batches-${group.slug}`}
|
|
492
|
-
aria-label={`Toggle ${group.slug} section`}
|
|
493
|
-
data-collapse-toggle
|
|
494
|
-
data-collapse-target={`project-batches-${group.slug}`}
|
|
495
|
-
data-collapse-key={`project:${group.slug}`}
|
|
496
|
-
>
|
|
497
|
-
<svg aria-hidden="true"><use href="#i-chevron-down" /></svg>
|
|
498
|
-
</button>
|
|
499
|
-
</header>
|
|
500
|
-
<div id={`project-batches-${group.slug}`}>
|
|
501
|
-
{group.batches.map((batch) => (
|
|
502
|
-
<BatchView key={batch.id} batch={batch} />
|
|
503
|
-
))}
|
|
504
|
-
</div>
|
|
505
|
-
</section>
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
function BatchView({ batch }: { batch: ProjectBatch }) {
|
|
510
|
-
// A batch names its filer in the header only when every entry agrees on one non-null
|
|
511
|
-
// value; a disagreement leaves the header filer-less and each row names its own inline.
|
|
512
|
-
const filedBy = batch.entries.map((entry) => entry.filed_by);
|
|
513
|
-
const allAgree = filedBy.every((filer) => filer === filedBy[0]);
|
|
514
|
-
const singleFiler = allAgree && filedBy[0] !== null ? filedBy[0] : null;
|
|
515
|
-
const mixedFilers = !allAgree && filedBy.some((filer) => filer !== null);
|
|
691
|
+
/**
|
|
692
|
+
* One project's entries as one dense table. The project is a heading, not a disclosure: batches
|
|
693
|
+
* stay a data concept behind the row selection and the one approve action. The select-all sits in
|
|
694
|
+
* the header and drives this table's drafts through ui.js's per-wrap state machine.
|
|
695
|
+
*/
|
|
696
|
+
function ProjectTable({ group }: { group: ProjectGroup }) {
|
|
697
|
+
const count = group.entries.length;
|
|
698
|
+
const drafts = group.entries.filter((entry) => entry.status === "draft").length;
|
|
699
|
+
const draftWord = drafts === 1 ? "draft" : "drafts";
|
|
700
|
+
const entryWord = count === 1 ? "entry" : "entries";
|
|
516
701
|
return (
|
|
517
|
-
<
|
|
518
|
-
<div class="
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
</label>
|
|
524
|
-
{singleFiler !== null ? <span class="batch-meta">{singleFiler}</span> : null}
|
|
525
|
-
<button
|
|
526
|
-
type="button"
|
|
527
|
-
class="btn btn-icon collapse-toggle"
|
|
528
|
-
aria-expanded="true"
|
|
529
|
-
aria-controls={`batch-table-${batch.id}`}
|
|
530
|
-
aria-label={`Toggle batch ${shortUlid(batch.id)}`}
|
|
531
|
-
data-collapse-toggle
|
|
532
|
-
data-collapse-target={`batch-table-${batch.id}`}
|
|
533
|
-
data-collapse-key={`batch:${batch.id}`}
|
|
534
|
-
>
|
|
535
|
-
<svg aria-hidden="true"><use href="#i-chevron-down" /></svg>
|
|
536
|
-
</button>
|
|
702
|
+
<section class="project-section">
|
|
703
|
+
<div class="section-head project-head">
|
|
704
|
+
<h2 class="section-title project-name">{group.slug}</h2>
|
|
705
|
+
<p class="section-lede project-meta">
|
|
706
|
+
{drafts > 0 ? `${drafts} ${draftWord} · ${count} ${entryWord}` : `${count} ${entryWord}`}
|
|
707
|
+
</p>
|
|
537
708
|
</div>
|
|
538
|
-
<div class="table-wrap
|
|
709
|
+
<div class="table-wrap table-dense">
|
|
539
710
|
<table>
|
|
540
711
|
<colgroup>
|
|
541
712
|
<col class="col-select" />
|
|
542
713
|
<col />
|
|
543
714
|
<col class="col-status" />
|
|
544
|
-
<col class="col-
|
|
715
|
+
<col class="col-filed" />
|
|
545
716
|
<col class="col-actions" />
|
|
546
717
|
</colgroup>
|
|
547
718
|
<thead>
|
|
548
719
|
<tr>
|
|
549
|
-
{/* The
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
<th scope="col">
|
|
720
|
+
{/* The select-all lives in the checkbox column: ui.js's initTables owns the
|
|
721
|
+
checked and indeterminate state machine for the wrap. No `name`, so it never
|
|
722
|
+
submits as an entry id; only the draft checkboxes post as `id`. */}
|
|
723
|
+
<th class="cell-select" scope="col">
|
|
724
|
+
<span class="sr-only">Select</span>
|
|
725
|
+
<label class="checkbox-label">
|
|
726
|
+
<input type="checkbox" data-select-all aria-label={`Select all drafts in ${group.slug}`} />
|
|
727
|
+
</label>
|
|
728
|
+
</th>
|
|
729
|
+
<th scope="col">Entry</th>
|
|
553
730
|
<th scope="col">Status</th>
|
|
554
|
-
<th scope="col">
|
|
731
|
+
<th class="cell-filed" scope="col">Filed</th>
|
|
555
732
|
<th scope="col">Actions</th>
|
|
556
733
|
</tr>
|
|
557
734
|
</thead>
|
|
558
735
|
<tbody>
|
|
559
|
-
{
|
|
560
|
-
<EntryRowView key={entry.id} entry={entry}
|
|
736
|
+
{group.entries.map((entry) => (
|
|
737
|
+
<EntryRowView key={entry.id} entry={entry} />
|
|
561
738
|
))}
|
|
562
739
|
</tbody>
|
|
563
740
|
</table>
|
|
564
741
|
</div>
|
|
565
|
-
</
|
|
742
|
+
</section>
|
|
566
743
|
);
|
|
567
744
|
}
|
|
568
745
|
|
|
746
|
+
/** The filed time, absolute and short: UTC date plus minutes, so the stamp reads the same wherever the server runs. */
|
|
747
|
+
function filedStamp(createdAt: number): { iso: string; short: string } {
|
|
748
|
+
const iso = new Date(createdAt).toISOString();
|
|
749
|
+
return { iso, short: `${iso.slice(0, 10)} ${iso.slice(11, 16)}` };
|
|
750
|
+
}
|
|
751
|
+
|
|
569
752
|
/**
|
|
570
|
-
* One row. The
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
753
|
+
* One row. The Entry cell leads with the title as a link over the file path; the checkbox names
|
|
754
|
+
* the entry for the batch form (and `data-name` hands it to the selection controller); it exists
|
|
755
|
+
* only on drafts, because an approve on any other status always refuses and a checkbox that can
|
|
756
|
+
* only refuse is noise (races are still reported per entry). The status chip sits in its own
|
|
757
|
+
* column; the three icon action slots always render, inapplicable ones disabled, so a
|
|
758
|
+
* double-click's second click lands on the disabled slot rather than a shifted action. Approve
|
|
759
|
+
* and reject target the list region and include the batch form's hidden filters, so an action
|
|
760
|
+
* round re-renders the region under the same filter; the editor link is always present.
|
|
578
761
|
*/
|
|
579
|
-
function EntryRowView({ entry
|
|
762
|
+
function EntryRowView({ entry }: { entry: EntryRow }) {
|
|
580
763
|
const title = entryTitle(entry);
|
|
764
|
+
const filed = filedStamp(entry.created_at);
|
|
581
765
|
const approve = entry.status === "draft";
|
|
582
766
|
const reject = entry.status === "draft" || entry.status === "approved";
|
|
583
767
|
return (
|
|
@@ -585,26 +769,33 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
|
|
|
585
769
|
<td class="cell-select">
|
|
586
770
|
{approve ? (
|
|
587
771
|
<label class="checkbox-label">
|
|
588
|
-
<input type="checkbox" name="id" value={entry.id} aria-label={`Select ${entry.file}`} />
|
|
772
|
+
<input type="checkbox" name="id" value={entry.id} data-name={entry.id} aria-label={`Select ${entry.file}`} />
|
|
589
773
|
</label>
|
|
590
774
|
) : null}
|
|
591
775
|
</td>
|
|
592
|
-
<td
|
|
776
|
+
<td>
|
|
593
777
|
<div class="entry-head">
|
|
594
|
-
<
|
|
595
|
-
|
|
778
|
+
<a class="entry-link truncate" href={`/entries/${entry.id}`}>{title}</a>
|
|
779
|
+
<span class="entry-file truncate">{entry.file}</span>
|
|
596
780
|
</div>
|
|
781
|
+
{/* A titled entry displays the title in place of the anchor text, so the anchor the entry
|
|
782
|
+
changes would otherwise reach no one but the editor. */}
|
|
783
|
+
{hasOwnTitle(entry) ? <span class="sr-only">{entry.anchor_text}</span> : null}
|
|
597
784
|
</td>
|
|
598
785
|
<td>
|
|
599
|
-
<span class={`tag ${STATUS_TAG[entry.status]}`}>
|
|
786
|
+
<span class={`tag ${STATUS_TAG[entry.status]}`}>
|
|
787
|
+
<span class="tag-dot"></span>
|
|
788
|
+
{STATUS_LABEL[entry.status]}
|
|
789
|
+
</span>
|
|
790
|
+
</td>
|
|
791
|
+
<td class="cell-filed">
|
|
792
|
+
<time class="num" datetime={filed.iso}>{filed.short}</time>
|
|
600
793
|
</td>
|
|
601
|
-
<td class="cell-file" title={entry.file}>{entry.file}</td>
|
|
602
794
|
<td class="cell-actions">
|
|
603
795
|
{approve ? (
|
|
604
796
|
<button
|
|
605
797
|
type="button"
|
|
606
|
-
class="btn btn-icon"
|
|
607
|
-
style="color: var(--success)"
|
|
798
|
+
class="btn btn-icon action-approve"
|
|
608
799
|
aria-label={`Approve ${entry.file}`}
|
|
609
800
|
hx-post={`/entries/${entry.id}/approve`}
|
|
610
801
|
hx-target="#entries-list"
|
|
@@ -617,15 +808,14 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
|
|
|
617
808
|
) : (
|
|
618
809
|
// The approve slot stays occupied on every non-draft row: a double-click's second click
|
|
619
810
|
// lands on the disabled button (pointer-events none) rather than the next slot.
|
|
620
|
-
<button type="button" class="btn btn-icon
|
|
811
|
+
<button type="button" class="btn btn-icon action-approve" aria-label={`Approve ${entry.file}`} disabled>
|
|
621
812
|
<svg aria-hidden="true"><use href="#i-check" /></svg>
|
|
622
813
|
</button>
|
|
623
814
|
)}
|
|
624
815
|
{reject ? (
|
|
625
816
|
<button
|
|
626
817
|
type="button"
|
|
627
|
-
class="btn btn-icon"
|
|
628
|
-
style="color: var(--danger)"
|
|
818
|
+
class="btn btn-icon action-reject"
|
|
629
819
|
aria-label={`Reject ${entry.file}`}
|
|
630
820
|
hx-post={`/entries/${entry.id}/reject`}
|
|
631
821
|
hx-target="#entries-list"
|
|
@@ -637,7 +827,7 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
|
|
|
637
827
|
</button>
|
|
638
828
|
) : (
|
|
639
829
|
// The reject slot stays occupied on applied and rejected rows for the same guard.
|
|
640
|
-
<button type="button" class="btn btn-icon
|
|
830
|
+
<button type="button" class="btn btn-icon action-reject" aria-label={`Reject ${entry.file}`} disabled>
|
|
641
831
|
<svg aria-hidden="true"><use href="#i-x" /></svg>
|
|
642
832
|
</button>
|
|
643
833
|
)}
|
|
@@ -649,26 +839,6 @@ function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: bo
|
|
|
649
839
|
);
|
|
650
840
|
}
|
|
651
841
|
|
|
652
|
-
/** The error box htmx failures clone into the list region; the retry button re-submits the form. */
|
|
653
|
-
function ErrorBoxTemplate() {
|
|
654
|
-
return (
|
|
655
|
-
<template id="error-box">
|
|
656
|
-
<div class="callout callout-danger" role="alert">
|
|
657
|
-
<div class="callout-icon">
|
|
658
|
-
<svg aria-hidden="true"><use href="#i-error" /></svg>
|
|
659
|
-
</div>
|
|
660
|
-
<div class="callout-content">
|
|
661
|
-
<div class="callout-title">The list could not be loaded</div>
|
|
662
|
-
<div class="callout-body">The filter request failed. Try it again.</div>
|
|
663
|
-
<div class="callout-actions">
|
|
664
|
-
<button class="btn btn-secondary btn-sm" type="button" data-retry>Retry</button>
|
|
665
|
-
</div>
|
|
666
|
-
</div>
|
|
667
|
-
</div>
|
|
668
|
-
</template>
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
842
|
/** The saved theme, restored before the stylesheet, or a light-theme visitor sees a dark flash. */
|
|
673
843
|
const THEME_RESTORE = raw(
|
|
674
844
|
'const saved = localStorage.getItem("cloudy-theme"); if (saved) document.documentElement.dataset.theme = saved;',
|
|
@@ -694,7 +864,7 @@ function PageHead({ title }: { title: string }) {
|
|
|
694
864
|
);
|
|
695
865
|
}
|
|
696
866
|
|
|
697
|
-
/** The sprite
|
|
867
|
+
/** The sprite in the body; cursor.js creates its own canvas and nothing else may enter that slot. */
|
|
698
868
|
function IconSprite() {
|
|
699
869
|
return <>{raw(ICON_SPRITE)}</>;
|
|
700
870
|
}
|
|
@@ -709,7 +879,7 @@ function PageModules() {
|
|
|
709
879
|
);
|
|
710
880
|
}
|
|
711
881
|
|
|
712
|
-
/** The full page for a plain navigation; the mount wraps it in the doctype. `signedIn` is whether the
|
|
882
|
+
/** The full page for a plain navigation; the mount wraps it in the doctype. `signedIn` is whether the sidebar offers sign-out. */
|
|
713
883
|
export function dashboardPage(
|
|
714
884
|
store: Store,
|
|
715
885
|
params: ListParams,
|
|
@@ -721,11 +891,20 @@ export function dashboardPage(
|
|
|
721
891
|
<html lang="en" data-theme="dark">
|
|
722
892
|
<PageHead title={`Entries · ${NAME}`} />
|
|
723
893
|
<body>
|
|
894
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
724
895
|
<IconSprite />
|
|
725
|
-
<
|
|
726
|
-
<
|
|
727
|
-
<
|
|
728
|
-
|
|
896
|
+
<Shell signedIn={signedIn} active="entries" draftCount={vm.counts.draft}>
|
|
897
|
+
<PageHeader vm={vm} />
|
|
898
|
+
<PageBanner notice={notice} />
|
|
899
|
+
{/* The persistent announcement slot for an action round: the banner may arrive already
|
|
900
|
+
populated through an out-of-band swap, which a live region does not reliably read. */}
|
|
901
|
+
<span id="list-live" class="sr-only" aria-live="polite"></span>
|
|
902
|
+
<section>
|
|
903
|
+
{/* The eyebrow alone names the section: the page title already reads "Entries",
|
|
904
|
+
and a second one of the same word stacks two display lines. */}
|
|
905
|
+
<div class="section-head">
|
|
906
|
+
<div class="label">Queue</div>
|
|
907
|
+
</div>
|
|
729
908
|
<FilterForm vm={vm} />
|
|
730
909
|
<div id="entries">
|
|
731
910
|
<div id="entries-loading" class="htmx-indicator" role="status">
|
|
@@ -733,12 +912,12 @@ export function dashboardPage(
|
|
|
733
912
|
Loading entries
|
|
734
913
|
</div>
|
|
735
914
|
<div id="entries-list">
|
|
736
|
-
<ListRegion vm={vm}
|
|
915
|
+
<ListRegion vm={vm} />
|
|
737
916
|
</div>
|
|
738
917
|
</div>
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
918
|
+
</section>
|
|
919
|
+
</Shell>
|
|
920
|
+
<div class="toast-stack" id="toast-stack"></div>
|
|
742
921
|
<PageModules />
|
|
743
922
|
</body>
|
|
744
923
|
</html>
|
|
@@ -746,10 +925,10 @@ export function dashboardPage(
|
|
|
746
925
|
}
|
|
747
926
|
|
|
748
927
|
/**
|
|
749
|
-
* The login
|
|
750
|
-
*
|
|
751
|
-
* itself is always cleared, and `next` rides along as a hidden input so a
|
|
752
|
-
* lands where the visitor was going.
|
|
928
|
+
* The login screen: a viewport-owning page-state with the sign-in card and no shell — the way out
|
|
929
|
+
* is signing in. `error` is the single failure message, shown as an alert callout inside the
|
|
930
|
+
* card's flow; the field itself is always cleared, and `next` rides along as a hidden input so a
|
|
931
|
+
* successful retry still lands where the visitor was going.
|
|
753
932
|
*/
|
|
754
933
|
export function loginPage(next: string | undefined, error: string | undefined = undefined): JSX.Element {
|
|
755
934
|
return (
|
|
@@ -757,20 +936,21 @@ export function loginPage(next: string | undefined, error: string | undefined =
|
|
|
757
936
|
<PageHead title={`Sign in · ${NAME}`} />
|
|
758
937
|
<body>
|
|
759
938
|
<IconSprite />
|
|
760
|
-
<
|
|
761
|
-
<
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
<h1 class="page-title">Sign in</h1>
|
|
767
|
-
<p class="page-lede">The dashboard is password protected. Enter the password to continue.</p>
|
|
768
|
-
</div>
|
|
769
|
-
</header>
|
|
770
|
-
{error !== undefined ? <DangerCallout title={error} /> : null}
|
|
771
|
-
<form method="post" action="/login" class="card login-card">
|
|
939
|
+
<main class="page-state">
|
|
940
|
+
<div class="page-state-card">
|
|
941
|
+
<div class="page-state-brand">
|
|
942
|
+
<div class="logo-name">{NAME}</div>
|
|
943
|
+
</div>
|
|
944
|
+
<form method="post" action="/login" class="card">
|
|
772
945
|
{next !== undefined ? <input type="hidden" name="next" value={next} /> : null}
|
|
773
|
-
<div class="card-
|
|
946
|
+
<div class="card-header">
|
|
947
|
+
<div>
|
|
948
|
+
<div class="card-title">Sign in</div>
|
|
949
|
+
<div class="card-subtitle">The dashboard is password protected. Enter the password to continue.</div>
|
|
950
|
+
</div>
|
|
951
|
+
</div>
|
|
952
|
+
<div class="card-content login-content">
|
|
953
|
+
{error !== undefined ? <DangerCallout title={error} /> : null}
|
|
774
954
|
<div class="field">
|
|
775
955
|
<label class="field-label" for="password">Password</label>
|
|
776
956
|
<input
|
|
@@ -782,32 +962,50 @@ export function loginPage(next: string | undefined, error: string | undefined =
|
|
|
782
962
|
/>
|
|
783
963
|
</div>
|
|
784
964
|
<div class="login-actions">
|
|
785
|
-
<button class="btn btn-
|
|
965
|
+
<button class="btn btn-fill" type="submit">Sign in</button>
|
|
786
966
|
</div>
|
|
787
967
|
</div>
|
|
788
968
|
</form>
|
|
789
|
-
</
|
|
790
|
-
</
|
|
969
|
+
</div>
|
|
970
|
+
</main>
|
|
791
971
|
<PageModules />
|
|
792
972
|
</body>
|
|
793
973
|
</html>
|
|
794
974
|
);
|
|
795
975
|
}
|
|
796
976
|
|
|
797
|
-
/**
|
|
977
|
+
/**
|
|
978
|
+
* The list region only, for htmx requests. The tabs, the header meta, the count line, the sidebar
|
|
979
|
+
* badge and the banner sit outside the main swap target, so they ride out-of-band swaps instead of
|
|
980
|
+
* going stale after an action round.
|
|
981
|
+
*/
|
|
798
982
|
export function entriesFragment(store: Store, params: ListParams, notice: ListNotice | undefined = undefined): JSX.Element {
|
|
799
|
-
|
|
983
|
+
const vm = loadList(store, params);
|
|
984
|
+
return (
|
|
985
|
+
<>
|
|
986
|
+
<DraftBadge count={vm.counts.draft} oob />
|
|
987
|
+
<PageBanner notice={notice} oob />
|
|
988
|
+
<StatusTabs counts={vm.counts} status={vm.status} oob />
|
|
989
|
+
<ListMeta vm={vm} oob />
|
|
990
|
+
<PageMeta draftCount={vm.counts.draft} total={vm.total} projects={vm.projects.length} oob />
|
|
991
|
+
<ListRegion vm={vm} />
|
|
992
|
+
</>
|
|
993
|
+
);
|
|
800
994
|
}
|
|
801
995
|
|
|
802
996
|
// ---- Entry editor ----
|
|
803
997
|
|
|
804
998
|
export type EditorViewModel = {
|
|
805
999
|
readonly entry: EntryRow;
|
|
1000
|
+
/** The entry's project slug, so the header's breadcrumb reads the name the queue groups by. */
|
|
1001
|
+
readonly projectSlug: string;
|
|
806
1002
|
readonly revisions: readonly RevisionRow[];
|
|
807
1003
|
readonly constraints: Constraints;
|
|
808
1004
|
/** The entry immediately before/after in the list's ascending-id walk, or null at the boundary. */
|
|
809
1005
|
readonly prevId: string | null;
|
|
810
1006
|
readonly nextId: string | null;
|
|
1007
|
+
/** The sidebar's queue badge, read once with the editor's own query. */
|
|
1008
|
+
readonly draftCount: number;
|
|
811
1009
|
};
|
|
812
1010
|
|
|
813
1011
|
/**
|
|
@@ -831,7 +1029,16 @@ export function loadEditor(store: Store, id: string): EditorViewModel | null {
|
|
|
831
1029
|
const entry = getEntry(store, id);
|
|
832
1030
|
if (entry === undefined) return null;
|
|
833
1031
|
const { prevId, nextId } = adjacentEntryIds(store, id);
|
|
834
|
-
|
|
1032
|
+
const projects = listProjects(store);
|
|
1033
|
+
return {
|
|
1034
|
+
entry,
|
|
1035
|
+
projectSlug: projects.find((project) => project.id === entry.project_id)?.slug ?? entry.project_id,
|
|
1036
|
+
revisions: listRevisions(store, id),
|
|
1037
|
+
constraints: parseConstraints(entry.constraints),
|
|
1038
|
+
prevId,
|
|
1039
|
+
nextId,
|
|
1040
|
+
draftCount: countEntriesByStatus(store).draft,
|
|
1041
|
+
};
|
|
835
1042
|
}
|
|
836
1043
|
|
|
837
1044
|
/** The tolerant parse for `entries.context`, mirroring `parseConstraints`: foreign rows may hold malformed JSON, and unparseable means no context rows, never a 500. */
|
|
@@ -850,12 +1057,17 @@ function parseContext(json: string): Record<string, unknown> {
|
|
|
850
1057
|
* The user-facing refusal messages, in the dashboard's own voice: name what failed, then the fix.
|
|
851
1058
|
* The store's refusal carries the values; only this function turns them into prose.
|
|
852
1059
|
*/
|
|
1060
|
+
/** A placeholder token as the copy shows it: one pair of braces around the bare name, even when the filed token already carries its own. */
|
|
1061
|
+
function bracedPlaceholder(placeholder: string): string {
|
|
1062
|
+
return `{${placeholder.replace(/^\{+|\}+$/g, "")}}`;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
853
1065
|
export function formatSaveRefusal(refusal: Exclude<HumanSaveRefusal, { kind: "unknown" }>): string {
|
|
854
1066
|
switch (refusal.kind) {
|
|
855
1067
|
case "max_len":
|
|
856
1068
|
return `This text is ${refusal.length} characters; the length limit is ${refusal.maxLen}. Shorten it to ${refusal.maxLen} characters or fewer.`;
|
|
857
1069
|
case "placeholder":
|
|
858
|
-
return `The text is missing the placeholder
|
|
1070
|
+
return `The text is missing the placeholder ${bracedPlaceholder(refusal.placeholder)}. Add ${bracedPlaceholder(refusal.placeholder)} to the text and save again.`;
|
|
859
1071
|
case "empty":
|
|
860
1072
|
return "Nothing to save. Enter text, or reject the entry.";
|
|
861
1073
|
case "status":
|
|
@@ -906,245 +1118,483 @@ function DangerCallout({ title, body }: { title: string; body?: string }) {
|
|
|
906
1118
|
);
|
|
907
1119
|
}
|
|
908
1120
|
|
|
909
|
-
|
|
910
|
-
* The editor region, swapped wholesale by a save or a transition (form + status tag + history +
|
|
911
|
-
* any refusal). The textarea carries `hx-preserve`, so a swap keeps the user's focus and typing
|
|
912
|
-
* while the server-rendered value stays what the last save left: the submitted text on a refusal.
|
|
913
|
-
* The `view` marker tells the approve and reject routes which region to answer with: this region,
|
|
914
|
-
* or the list's. The buttons carry htmx only; the save button keeps the native submit.
|
|
915
|
-
*/
|
|
916
|
-
function EditorRegion({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
|
|
917
|
-
const text = state?.submittedText ?? vm.entry.human_text ?? vm.entry.agent_draft ?? "";
|
|
918
|
-
const approve = vm.entry.status === "draft";
|
|
919
|
-
const reject = vm.entry.status === "draft" || vm.entry.status === "approved";
|
|
920
|
-
return (
|
|
921
|
-
<div id="editor-region">
|
|
922
|
-
{state?.refusal !== undefined ? (
|
|
923
|
-
<DangerCallout title={state.refusal.title} body={state.refusal.body} />
|
|
924
|
-
) : null}
|
|
925
|
-
<form
|
|
926
|
-
id="editor-form"
|
|
927
|
-
class="card"
|
|
928
|
-
method="post"
|
|
929
|
-
action={`/entries/${vm.entry.id}/save`}
|
|
930
|
-
hx-post={`/entries/${vm.entry.id}/save`}
|
|
931
|
-
hx-target="#editor-view"
|
|
932
|
-
hx-swap="outerHTML"
|
|
933
|
-
>
|
|
934
|
-
<input type="hidden" name="view" value="editor" />
|
|
935
|
-
<div class="card-header">
|
|
936
|
-
<div class="card-title editor-title">{entryTitle(vm.entry)}</div>
|
|
937
|
-
<span class={`tag ${STATUS_TAG[vm.entry.status]}`}>
|
|
938
|
-
{STATUS_LABEL[vm.entry.status]}
|
|
939
|
-
</span>
|
|
940
|
-
</div>
|
|
941
|
-
<div class="card-content">
|
|
942
|
-
<textarea id="editor-text" class="input" name="text" rows={10} aria-label="Entry text" hx-preserve="true">
|
|
943
|
-
{text}
|
|
944
|
-
</textarea>
|
|
945
|
-
<div class="editor-actions">
|
|
946
|
-
<button class="btn btn-primary" type="submit">Save text</button>
|
|
947
|
-
{approve ? (
|
|
948
|
-
<button
|
|
949
|
-
type="button"
|
|
950
|
-
class="btn btn-secondary"
|
|
951
|
-
hx-post={`/entries/${vm.entry.id}/approve`}
|
|
952
|
-
hx-target="#editor-region"
|
|
953
|
-
hx-swap="outerHTML"
|
|
954
|
-
hx-indicator="#editor-action-loading"
|
|
955
|
-
hx-disabled-elt="this"
|
|
956
|
-
>
|
|
957
|
-
Approve draft
|
|
958
|
-
</button>
|
|
959
|
-
) : vm.entry.status === "approved" ? (
|
|
960
|
-
// Same slot-occupancy guard as the list row: the second click of a double-click on
|
|
961
|
-
// the just-approving button would otherwise land on the Reject button that shifts
|
|
962
|
-
// into this slot in the swapped region.
|
|
963
|
-
<button type="button" class="btn btn-secondary" disabled>
|
|
964
|
-
Approve draft
|
|
965
|
-
</button>
|
|
966
|
-
) : null}
|
|
967
|
-
{reject ? (
|
|
968
|
-
<button
|
|
969
|
-
type="button"
|
|
970
|
-
class="btn btn-danger"
|
|
971
|
-
hx-post={`/entries/${vm.entry.id}/reject`}
|
|
972
|
-
hx-target="#editor-region"
|
|
973
|
-
hx-swap="outerHTML"
|
|
974
|
-
hx-indicator="#editor-action-loading"
|
|
975
|
-
hx-disabled-elt="this"
|
|
976
|
-
>
|
|
977
|
-
Reject
|
|
978
|
-
</button>
|
|
979
|
-
) : null}
|
|
980
|
-
<span id="editor-action-loading" class="htmx-indicator" role="status">
|
|
981
|
-
<span class="spinner" aria-hidden="true"></span>
|
|
982
|
-
Updating entry
|
|
983
|
-
</span>
|
|
984
|
-
</div>
|
|
985
|
-
</div>
|
|
986
|
-
</form>
|
|
987
|
-
<RevisionHistory revisions={vm.revisions} />
|
|
988
|
-
</div>
|
|
989
|
-
);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
function RevisionHistory({ revisions }: { revisions: readonly RevisionRow[] }) {
|
|
1121
|
+
function InfoCallout({ title, body }: { title: string; body?: string }) {
|
|
993
1122
|
return (
|
|
994
|
-
<div class="
|
|
995
|
-
<div class="
|
|
996
|
-
<
|
|
1123
|
+
<div class="callout callout-info">
|
|
1124
|
+
<div class="callout-icon">
|
|
1125
|
+
<svg aria-hidden="true"><use href="#i-info" /></svg>
|
|
997
1126
|
</div>
|
|
998
|
-
<div class="
|
|
999
|
-
{
|
|
1000
|
-
|
|
1001
|
-
) : (
|
|
1002
|
-
<ul class="revision-list">
|
|
1003
|
-
{revisions.map((revision) => (
|
|
1004
|
-
<li class="revision" key={revision.id}>
|
|
1005
|
-
<div class="revision-head">
|
|
1006
|
-
<span class={`tag tag-data ${STATUS_TAG[revision.status]}`}>{STATUS_LABEL[revision.status]}</span>
|
|
1007
|
-
<time class="revision-time" datetime={new Date(revision.created_at).toISOString()}>
|
|
1008
|
-
{new Date(revision.created_at).toLocaleString()}
|
|
1009
|
-
</time>
|
|
1010
|
-
<button class="btn btn-secondary btn-sm" type="button" data-use-revision>
|
|
1011
|
-
Use
|
|
1012
|
-
</button>
|
|
1013
|
-
</div>
|
|
1014
|
-
<div class="revision-text">{revision.human_text}</div>
|
|
1015
|
-
</li>
|
|
1016
|
-
))}
|
|
1017
|
-
</ul>
|
|
1018
|
-
)}
|
|
1127
|
+
<div class="callout-content">
|
|
1128
|
+
<div class="callout-title">{title}</div>
|
|
1129
|
+
{body !== undefined ? <div class="callout-body">{body}</div> : null}
|
|
1019
1130
|
</div>
|
|
1020
1131
|
</div>
|
|
1021
1132
|
);
|
|
1022
1133
|
}
|
|
1023
1134
|
|
|
1024
|
-
/** The
|
|
1025
|
-
function
|
|
1026
|
-
const maxLen = constraints.maxLen;
|
|
1027
|
-
const placeholders = constraints.placeholders;
|
|
1028
|
-
const tone = constraints.tone;
|
|
1029
|
-
const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined;
|
|
1030
|
-
if (!hasAny) return null;
|
|
1135
|
+
/** The entry's status tag, in the page header; transitions re-render it out of band. */
|
|
1136
|
+
function EntryStatusTag({ status, oob = false }: { status: EntryStatus; oob?: boolean }) {
|
|
1031
1137
|
return (
|
|
1032
|
-
<
|
|
1033
|
-
<
|
|
1034
|
-
<
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
<div class="constraint-row">
|
|
1039
|
-
<div class="constraint-label">Length</div>
|
|
1040
|
-
<div class="constraint-value">
|
|
1041
|
-
<span id="char-count" class="num char-count" data-max-len={maxLen}>
|
|
1042
|
-
{text.length} / {maxLen}
|
|
1043
|
-
</span>
|
|
1044
|
-
</div>
|
|
1045
|
-
</div>
|
|
1046
|
-
) : null}
|
|
1047
|
-
{placeholders.length > 0 ? (
|
|
1048
|
-
<div class="constraint-row">
|
|
1049
|
-
<div class="constraint-label">Placeholders</div>
|
|
1050
|
-
<ul class="placeholder-list">
|
|
1051
|
-
{placeholders.map((placeholder) => {
|
|
1052
|
-
const present = text.includes(placeholder);
|
|
1053
|
-
return (
|
|
1054
|
-
<li class="placeholder-item" data-placeholder={placeholder} key={placeholder}>
|
|
1055
|
-
<span class={`placeholder-state${present ? " is-present" : ""}`}>
|
|
1056
|
-
{present ? "Present" : "Missing"}
|
|
1057
|
-
</span>
|
|
1058
|
-
<span class="placeholder-token">{"{" + placeholder + "}"}</span>
|
|
1059
|
-
</li>
|
|
1060
|
-
);
|
|
1061
|
-
})}
|
|
1062
|
-
</ul>
|
|
1063
|
-
</div>
|
|
1064
|
-
) : null}
|
|
1065
|
-
{tone !== undefined ? (
|
|
1066
|
-
<div class="constraint-row">
|
|
1067
|
-
<div class="constraint-label">Tone</div>
|
|
1068
|
-
<div class="constraint-value">{tone}</div>
|
|
1069
|
-
</div>
|
|
1070
|
-
) : null}
|
|
1071
|
-
</div>
|
|
1072
|
-
</div>
|
|
1138
|
+
<span id="entry-status" hx-swap-oob={oob ? "true" : undefined}>
|
|
1139
|
+
<span class={`tag ${STATUS_TAG[status]}`}>
|
|
1140
|
+
<span class="tag-dot"></span>
|
|
1141
|
+
{STATUS_LABEL[status]}
|
|
1142
|
+
</span>
|
|
1143
|
+
</span>
|
|
1073
1144
|
);
|
|
1074
1145
|
}
|
|
1075
1146
|
|
|
1076
|
-
/**
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1147
|
+
/** One header nav control: a secondary button linking to the neighbour, or an inert placeholder at the boundary. */
|
|
1148
|
+
function EditorNavButton({ side, targetId }: { side: "prev" | "next"; targetId: string | null }) {
|
|
1149
|
+
const label = side === "prev" ? "Previous entry" : "Next entry";
|
|
1150
|
+
const icon = (
|
|
1151
|
+
<svg aria-hidden="true"><use href={side === "prev" ? "#i-chevron-left" : "#i-chevron-right"} /></svg>
|
|
1152
|
+
);
|
|
1153
|
+
if (targetId === null) {
|
|
1154
|
+
return (
|
|
1155
|
+
<button class="btn btn-secondary" type="button" disabled aria-label={label}>
|
|
1156
|
+
{icon}
|
|
1157
|
+
</button>
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1084
1160
|
return (
|
|
1085
|
-
<
|
|
1161
|
+
<a class="btn btn-secondary" href={`/entries/${targetId}`} aria-label={label}>
|
|
1162
|
+
{icon}
|
|
1163
|
+
</a>
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* The editor's page header: the breadcrumb on its own line, then the entry title with the status
|
|
1169
|
+
* tag, repo link, file and filed time under it, and the previous/next pair in the actions cell.
|
|
1170
|
+
*/
|
|
1171
|
+
function EditorHeader({ vm }: { vm: EditorViewModel }) {
|
|
1172
|
+
const entry = vm.entry;
|
|
1173
|
+
const filed = filedStamp(entry.created_at);
|
|
1174
|
+
return (
|
|
1175
|
+
<header class="page-header">
|
|
1176
|
+
<nav class="breadcrumb" aria-label="Breadcrumb">
|
|
1177
|
+
<a class="breadcrumb-item" href="/">Entries</a>
|
|
1178
|
+
<span class="breadcrumb-sep" aria-hidden="true"></span>
|
|
1179
|
+
<span class="breadcrumb-item current">{vm.projectSlug}</span>
|
|
1180
|
+
</nav>
|
|
1181
|
+
<div class="page-header-body">
|
|
1182
|
+
<h1 class="page-title editor-title">{entryTitle(entry)}</h1>
|
|
1183
|
+
<div class="page-meta">
|
|
1184
|
+
<EntryStatusTag status={entry.status} />
|
|
1185
|
+
<span class="page-meta-item"><RepoValue repo={entry.repo} /></span>
|
|
1186
|
+
<span class="page-meta-item"><span class="page-file truncate">{entry.file}</span></span>
|
|
1187
|
+
<span class="page-meta-item">filed <time class="num" datetime={filed.iso}>{filed.short}</time></span>
|
|
1188
|
+
</div>
|
|
1189
|
+
</div>
|
|
1190
|
+
<div class="toolbar">
|
|
1191
|
+
<EditorNavButton side="prev" targetId={vm.prevId} />
|
|
1192
|
+
<EditorNavButton side="next" targetId={vm.nextId} />
|
|
1193
|
+
</div>
|
|
1194
|
+
</header>
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Whether the change touches both sides: a substitution or rewrite then renders two rows, while an
|
|
1200
|
+
* addition-only or removal-only edit stays on the unified line. On one line a substitution reads as
|
|
1201
|
+
* two differently tinted copies of the same word ("`clauth" then "clauth"), so the two whole rows
|
|
1202
|
+
* keep the sentence readable.
|
|
1203
|
+
*/
|
|
1204
|
+
function hasBothSides(diff: readonly WordDiffToken[]): boolean {
|
|
1205
|
+
return diff.some((token) => token.kind === "removed") && diff.some((token) => token.kind === "added");
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** One side of a two-row change: the anchor without the additions, or the proposal without the removals, each a whole sentence. */
|
|
1209
|
+
function diffSide(diff: readonly WordDiffToken[], side: "anchor" | "proposal"): Child[] {
|
|
1210
|
+
const dropped = side === "anchor" ? "added" : "removed";
|
|
1211
|
+
const mark = side === "anchor" ? "diff-remove" : "diff-add";
|
|
1212
|
+
return diff
|
|
1213
|
+
.filter((token) => token.kind !== dropped)
|
|
1214
|
+
.map((token, index) =>
|
|
1215
|
+
token.kind === "same" ? token.text : <span class={mark} key={index}>{token.text}</span>,
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* The change card: the anchored snippet with the anchor line marked, diffed against the compare
|
|
1221
|
+
* text when one is given. A one-sided edit renders the unified stream, so the marks sit in place
|
|
1222
|
+
* while the kept words read once; a change on both sides renders the file text and the proposal as
|
|
1223
|
+
* two whole rows. A null or empty compare renders the anchor plain. The diff route re-renders this
|
|
1224
|
+
* card against the textarea's live value.
|
|
1225
|
+
*/
|
|
1226
|
+
export function currentTextCard(entry: EntryRow, compareText: string | null): JSX.Element {
|
|
1227
|
+
const diff = compareText === null || compareText === "" ? null : diffWords(entry.anchor_text, compareText);
|
|
1228
|
+
const twoRow = diff !== null && hasBothSides(diff);
|
|
1229
|
+
return (
|
|
1230
|
+
<div class="card current-text-card" id="current-text">
|
|
1086
1231
|
<div class="card-header">
|
|
1087
|
-
<div
|
|
1232
|
+
<div>
|
|
1233
|
+
<div class="card-title">The change</div>
|
|
1234
|
+
<div class="card-subtitle">Removed words tint red; added words tint green.</div>
|
|
1235
|
+
</div>
|
|
1088
1236
|
</div>
|
|
1089
1237
|
<div class="card-content">
|
|
1090
|
-
<
|
|
1091
|
-
<
|
|
1092
|
-
<
|
|
1238
|
+
<pre class={twoRow ? "diff-code editor-pane diff-rows" : "diff-code editor-pane"}>
|
|
1239
|
+
<span class="diff-line">{entry.anchor_before}</span>
|
|
1240
|
+
<span class="diff-line diff-anchor annot-mark">
|
|
1241
|
+
{diff === null
|
|
1242
|
+
? entry.anchor_text
|
|
1243
|
+
: twoRow
|
|
1244
|
+
? diffSide(diff, "anchor")
|
|
1245
|
+
: diff.map((token, index) =>
|
|
1246
|
+
token.kind === "same" ? (
|
|
1247
|
+
token.text
|
|
1248
|
+
) : (
|
|
1249
|
+
<span class={token.kind === "removed" ? "diff-remove" : "diff-add"} key={index}>{token.text}</span>
|
|
1250
|
+
),
|
|
1251
|
+
)}
|
|
1252
|
+
</span>
|
|
1253
|
+
<span class="diff-line">{entry.anchor_after}</span>
|
|
1254
|
+
{twoRow && diff !== null ? <span class="diff-line diff-proposed">{diffSide(diff, "proposal")}</span> : null}
|
|
1255
|
+
</pre>
|
|
1256
|
+
</div>
|
|
1257
|
+
</div>
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/** One disclosure section of the editor's single support card. */
|
|
1262
|
+
function SupportSection({ label, children }: { label: string; children: Child }) {
|
|
1263
|
+
return (
|
|
1264
|
+
<details class="disclosure support-section" open>
|
|
1265
|
+
<summary>
|
|
1266
|
+
<svg aria-hidden="true"><use href="#i-chevron-right" /></svg>
|
|
1267
|
+
<span>{label}</span>
|
|
1268
|
+
</summary>
|
|
1269
|
+
<div class="disclosure-body">{children}</div>
|
|
1270
|
+
</details>
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** The entry's provenance rows: file hash, filer, stale note. */
|
|
1275
|
+
function ProvenanceTable({ entry }: { entry: EntryRow }) {
|
|
1276
|
+
return (
|
|
1277
|
+
<table class="info-table">
|
|
1278
|
+
<tr>
|
|
1279
|
+
<td>File hash</td>
|
|
1280
|
+
<td>{entry.file_hash}</td>
|
|
1281
|
+
</tr>
|
|
1282
|
+
{entry.filed_by !== null ? (
|
|
1283
|
+
<tr>
|
|
1284
|
+
<td>Filed by</td>
|
|
1285
|
+
<td>{entry.filed_by}</td>
|
|
1286
|
+
</tr>
|
|
1287
|
+
) : null}
|
|
1288
|
+
{entry.stale_note !== null ? (
|
|
1289
|
+
<tr>
|
|
1290
|
+
<td>Stale note</td>
|
|
1291
|
+
<td>{entry.stale_note}</td>
|
|
1292
|
+
</tr>
|
|
1293
|
+
) : null}
|
|
1294
|
+
</table>
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* The entry's agent-supplied context rows, then the provenance behind a disclosure. An entry
|
|
1300
|
+
* without context rows shows the provenance directly: a section holding only a "More" toggle reads
|
|
1301
|
+
* as an empty card. The anchor, repo, file and filed time live in the page header, so no row here
|
|
1302
|
+
* repeats them.
|
|
1303
|
+
*/
|
|
1304
|
+
function DetailsSection({ entry }: { entry: EntryRow }) {
|
|
1305
|
+
const context = parseContext(entry.context);
|
|
1306
|
+
const rows = Object.entries(context);
|
|
1307
|
+
return (
|
|
1308
|
+
<SupportSection label="Details">
|
|
1309
|
+
{rows.length === 0 ? (
|
|
1310
|
+
<ProvenanceTable entry={entry} />
|
|
1311
|
+
) : (
|
|
1312
|
+
<>
|
|
1313
|
+
<table class="info-table">
|
|
1314
|
+
{rows.map(([key, value]) => (
|
|
1315
|
+
<tr key={key}>
|
|
1316
|
+
<td>{key}</td>
|
|
1317
|
+
<td>{typeof value === "string" ? value : JSON.stringify(value)}</td>
|
|
1318
|
+
</tr>
|
|
1319
|
+
))}
|
|
1320
|
+
</table>
|
|
1321
|
+
<details class="disclosure context-secondary">
|
|
1322
|
+
<summary>
|
|
1323
|
+
<svg aria-hidden="true"><use href="#i-chevron-right" /></svg>
|
|
1324
|
+
<span>More</span>
|
|
1325
|
+
</summary>
|
|
1326
|
+
<div class="disclosure-body">
|
|
1327
|
+
<ProvenanceTable entry={entry} />
|
|
1328
|
+
</div>
|
|
1329
|
+
</details>
|
|
1330
|
+
</>
|
|
1331
|
+
)}
|
|
1332
|
+
</SupportSection>
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** The audit log of saved revisions: the shipped timeline, one dot per entry. */
|
|
1337
|
+
function RevisionHistorySection({ revisions, editable }: { revisions: readonly RevisionRow[]; editable: boolean }) {
|
|
1338
|
+
return (
|
|
1339
|
+
<SupportSection label="Revision history">
|
|
1340
|
+
{revisions.length === 0 ? (
|
|
1341
|
+
<div class="empty-state">
|
|
1342
|
+
<div class="empty-title">No saved revisions yet</div>
|
|
1093
1343
|
</div>
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1344
|
+
) : (
|
|
1345
|
+
<ol class="timeline">
|
|
1346
|
+
{revisions.map((revision) => (
|
|
1347
|
+
<li class="timeline-item" key={revision.id}>
|
|
1348
|
+
<span class={`timeline-dot ${STATUS_DOT[revision.status]}`} aria-hidden="true"></span>
|
|
1349
|
+
<div>
|
|
1350
|
+
<div class="timeline-head">
|
|
1351
|
+
<span class={`tag tag-data ${STATUS_TAG[revision.status]}`}>{STATUS_LABEL[revision.status]}</span>
|
|
1352
|
+
<time class="timeline-time" datetime={new Date(revision.created_at).toISOString()}>
|
|
1353
|
+
{new Date(revision.created_at).toLocaleString()}
|
|
1354
|
+
</time>
|
|
1355
|
+
{/* A terminal entry has no textarea to load into; the button would be dead. */}
|
|
1356
|
+
{editable ? (
|
|
1357
|
+
<button class="btn btn-secondary btn-sm" type="button" data-use-revision>
|
|
1358
|
+
Use
|
|
1359
|
+
</button>
|
|
1360
|
+
) : null}
|
|
1361
|
+
</div>
|
|
1362
|
+
<div class="timeline-body clamp-2">{revision.human_text}</div>
|
|
1363
|
+
</div>
|
|
1364
|
+
</li>
|
|
1365
|
+
))}
|
|
1366
|
+
</ol>
|
|
1367
|
+
)}
|
|
1368
|
+
</SupportSection>
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* The live metrics under the textarea: the length against the cap, each required placeholder's
|
|
1374
|
+
* present/missing state, and the requested tone. Dashboard.js re-syncs these as the user types.
|
|
1375
|
+
*/
|
|
1376
|
+
function ConstraintRow({ constraints, text }: { constraints: Constraints; text: string }) {
|
|
1377
|
+
const maxLen = constraints.maxLen;
|
|
1378
|
+
const placeholders = constraints.placeholders;
|
|
1379
|
+
const tone = constraints.tone;
|
|
1380
|
+
const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined;
|
|
1381
|
+
if (!hasAny) return null;
|
|
1382
|
+
return (
|
|
1383
|
+
<div class="constraint-row">
|
|
1384
|
+
{maxLen !== undefined ? (
|
|
1385
|
+
<span id="char-count" class="num char-count" data-max-len={maxLen}>
|
|
1386
|
+
{text.length} / {maxLen}
|
|
1387
|
+
</span>
|
|
1388
|
+
) : null}
|
|
1389
|
+
{placeholders.length > 0 ? (
|
|
1390
|
+
<ul class="placeholder-list">
|
|
1391
|
+
{placeholders.map((placeholder) => {
|
|
1392
|
+
const present = text.includes(placeholder);
|
|
1393
|
+
return (
|
|
1394
|
+
<li class="placeholder-item" data-placeholder={placeholder} key={placeholder}>
|
|
1395
|
+
<span class={`placeholder-state${present ? " is-present" : ""}`}>
|
|
1396
|
+
{present ? "Present" : "Missing"}
|
|
1397
|
+
</span>
|
|
1398
|
+
<span class="tag tag-data">{bracedPlaceholder(placeholder)}</span>
|
|
1399
|
+
</li>
|
|
1400
|
+
);
|
|
1401
|
+
})}
|
|
1402
|
+
</ul>
|
|
1403
|
+
) : null}
|
|
1404
|
+
{tone !== undefined ? <span class="constraint-tone">Tone: {tone}</span> : null}
|
|
1405
|
+
</div>
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* The proposal card for a draft or an approved entry: the textarea prefilled with what agents will
|
|
1411
|
+
* read, the constraints under it, and the one primary action. Saving a draft's text approves it,
|
|
1412
|
+
* so the primary reads "Save and approve" there; the old separate approve button silently discarded
|
|
1413
|
+
* edits and is gone. The textarea carries `hx-preserve`, so a swap keeps the user's focus and
|
|
1414
|
+
* typing while the server-rendered value stays what the last save left: the submitted text on a
|
|
1415
|
+
* refusal. The save button keeps the native submit; the reject button is an htmx transition.
|
|
1416
|
+
*/
|
|
1417
|
+
function ProposalForm({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
|
|
1418
|
+
const entry = vm.entry;
|
|
1419
|
+
const text = state?.submittedText ?? entry.human_text ?? entry.agent_draft ?? "";
|
|
1420
|
+
const draft = entry.status === "draft";
|
|
1421
|
+
const reject = entry.status === "draft" || entry.status === "approved";
|
|
1422
|
+
return (
|
|
1423
|
+
<form
|
|
1424
|
+
id="editor-form"
|
|
1425
|
+
class="card"
|
|
1426
|
+
method="post"
|
|
1427
|
+
action={`/entries/${entry.id}/save`}
|
|
1428
|
+
hx-post={`/entries/${entry.id}/save`}
|
|
1429
|
+
hx-target="#editor-view"
|
|
1430
|
+
hx-swap="outerHTML"
|
|
1431
|
+
hx-indicator="#editor-action-loading"
|
|
1432
|
+
>
|
|
1433
|
+
<input type="hidden" name="view" value="editor" />
|
|
1434
|
+
<div class="card-header">
|
|
1435
|
+
<div>
|
|
1436
|
+
<div class="card-title">Proposed text</div>
|
|
1437
|
+
<div class="card-subtitle">
|
|
1438
|
+
{draft
|
|
1439
|
+
? "Saving the text approves the entry and releases it to agents."
|
|
1440
|
+
: "Saving updates the text agents will apply."}
|
|
1441
|
+
</div>
|
|
1442
|
+
</div>
|
|
1443
|
+
</div>
|
|
1444
|
+
<div class="card-content">
|
|
1445
|
+
<textarea
|
|
1446
|
+
id="editor-text"
|
|
1447
|
+
class="input editor-pane"
|
|
1448
|
+
name="text"
|
|
1449
|
+
aria-label="Entry text"
|
|
1450
|
+
hx-preserve="true"
|
|
1451
|
+
data-autosize
|
|
1452
|
+
hx-post={`/entries/${entry.id}/diff`}
|
|
1453
|
+
hx-trigger="input changed delay:250ms"
|
|
1454
|
+
hx-target="#current-text"
|
|
1100
1455
|
hx-swap="outerHTML"
|
|
1456
|
+
hx-sync="this:replace"
|
|
1101
1457
|
>
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1458
|
+
{text}
|
|
1459
|
+
</textarea>
|
|
1460
|
+
<ConstraintRow constraints={vm.constraints} text={text} />
|
|
1461
|
+
<div class="editor-actions">
|
|
1462
|
+
<button class="btn btn-fill" type="submit" hx-disabled-elt="this">
|
|
1463
|
+
{draft ? "Save and approve" : "Save text"}
|
|
1464
|
+
</button>
|
|
1465
|
+
{reject ? (
|
|
1466
|
+
<button
|
|
1467
|
+
type="button"
|
|
1468
|
+
class="btn btn-danger"
|
|
1469
|
+
hx-post={`/entries/${entry.id}/reject`}
|
|
1470
|
+
hx-target="#editor-view"
|
|
1471
|
+
hx-swap="outerHTML"
|
|
1472
|
+
hx-indicator="#editor-action-loading"
|
|
1473
|
+
hx-disabled-elt="this"
|
|
1111
1474
|
>
|
|
1112
|
-
|
|
1113
|
-
</
|
|
1114
|
-
|
|
1115
|
-
<
|
|
1116
|
-
<
|
|
1117
|
-
|
|
1118
|
-
|
|
1475
|
+
Reject
|
|
1476
|
+
</button>
|
|
1477
|
+
) : null}
|
|
1478
|
+
<span id="editor-action-loading" class="htmx-indicator" role="status">
|
|
1479
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
1480
|
+
Updating entry
|
|
1481
|
+
</span>
|
|
1482
|
+
</div>
|
|
1483
|
+
</div>
|
|
1484
|
+
</form>
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/** The read-only proposal for a terminal entry: the settled text in the comparison pane. */
|
|
1489
|
+
function TerminalTextCard({ entry }: { entry: EntryRow }) {
|
|
1490
|
+
return (
|
|
1491
|
+
<div class="card">
|
|
1492
|
+
<div class="card-header">
|
|
1493
|
+
<div>
|
|
1494
|
+
<div class="card-title">Settled text</div>
|
|
1495
|
+
</div>
|
|
1496
|
+
</div>
|
|
1497
|
+
<div class="card-content">
|
|
1498
|
+
<pre class="diff-code editor-pane">{entry.human_text ?? ""}</pre>
|
|
1119
1499
|
</div>
|
|
1120
1500
|
</div>
|
|
1121
1501
|
);
|
|
1122
1502
|
}
|
|
1123
1503
|
|
|
1504
|
+
/**
|
|
1505
|
+
* The proposal pane: the editable card for a draft or approved entry, or the settled text of a
|
|
1506
|
+
* terminal one. A terminal entry has no change card, so the pane spans the full workspace. The
|
|
1507
|
+
* swap region lives here; the refusal and read-only notices sit above the pair.
|
|
1508
|
+
*/
|
|
1509
|
+
function EditorRegion({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
|
|
1510
|
+
const terminal = vm.entry.status === "applied" || vm.entry.status === "rejected";
|
|
1511
|
+
return (
|
|
1512
|
+
<div id="editor-region" tabindex={-1} class={terminal ? "editor-region-wide" : undefined}>
|
|
1513
|
+
{terminal ? <TerminalTextCard entry={vm.entry} /> : <ProposalForm vm={vm} state={state} />}
|
|
1514
|
+
</div>
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* The notes section: the agent's notes (from `constraints.notes`, read-only) above the human's own
|
|
1520
|
+
* notes as an editable textarea with its own save. The textarea carries `hx-preserve`, so any swap
|
|
1521
|
+
* (a text save, an image add) keeps the human's in-progress note; the save targets the whole
|
|
1522
|
+
* editor view like the save form, so the saved value re-renders in place. The section always
|
|
1523
|
+
* renders: the human's notes need a home even before the agent sent any.
|
|
1524
|
+
*/
|
|
1525
|
+
function NotesSection({ vm }: { vm: EditorViewModel }) {
|
|
1526
|
+
return (
|
|
1527
|
+
<SupportSection label="Notes">
|
|
1528
|
+
<table class="info-table">
|
|
1529
|
+
<tr>
|
|
1530
|
+
<td>Agent's notes</td>
|
|
1531
|
+
<td>{vm.constraints.notes ?? "None."}</td>
|
|
1532
|
+
</tr>
|
|
1533
|
+
</table>
|
|
1534
|
+
<form
|
|
1535
|
+
id="notes-form"
|
|
1536
|
+
method="post"
|
|
1537
|
+
action={`/entries/${vm.entry.id}/notes`}
|
|
1538
|
+
hx-post={`/entries/${vm.entry.id}/notes`}
|
|
1539
|
+
hx-target="#editor-view"
|
|
1540
|
+
hx-swap="outerHTML"
|
|
1541
|
+
>
|
|
1542
|
+
<div class="field notes-field">
|
|
1543
|
+
<label class="field-label" for="editor-notes">Your notes</label>
|
|
1544
|
+
<textarea
|
|
1545
|
+
id="editor-notes"
|
|
1546
|
+
class="input"
|
|
1547
|
+
name="notes"
|
|
1548
|
+
rows={4}
|
|
1549
|
+
aria-label="Your notes"
|
|
1550
|
+
hx-preserve="true"
|
|
1551
|
+
>
|
|
1552
|
+
{vm.entry.human_notes ?? ""}
|
|
1553
|
+
</textarea>
|
|
1554
|
+
</div>
|
|
1555
|
+
<div class="notes-actions">
|
|
1556
|
+
<button class="btn btn-secondary btn-sm" type="submit">Save notes</button>
|
|
1557
|
+
</div>
|
|
1558
|
+
</form>
|
|
1559
|
+
</SupportSection>
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1124
1563
|
/** An image item renders only when it still matches the prefixes the wire validated; a foreign row can hold anything, so a non-matching item shows a note instead, never raw. */
|
|
1125
1564
|
function isRenderableImage(item: string): boolean {
|
|
1126
1565
|
return item.startsWith("data:image/") || /^https?:\/\//.test(item);
|
|
1127
1566
|
}
|
|
1128
1567
|
|
|
1129
1568
|
/**
|
|
1130
|
-
* The images attached to an entry, one
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
* editor view like the save form, so a change re-renders the card in place.
|
|
1569
|
+
* The images attached to an entry, one section: each valid item as a real `img` (sized by css), an
|
|
1570
|
+
* invalid one as a "not shown" note, a remove form per image, and the one-file upload as the
|
|
1571
|
+
* system dropzone. Both forms target the whole editor view like the save form, so a change
|
|
1572
|
+
* re-renders the section in place.
|
|
1135
1573
|
*/
|
|
1136
|
-
function
|
|
1574
|
+
function ImagesSection({ entry }: { entry: EntryRow }) {
|
|
1137
1575
|
const images = parseEntryImages(entry.images);
|
|
1138
1576
|
return (
|
|
1139
|
-
<
|
|
1140
|
-
<div class="
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1577
|
+
<SupportSection label="Images">
|
|
1578
|
+
<div class="dropzone">
|
|
1579
|
+
{/* The native input is the picker and its accessible name; the zone is its paint.
|
|
1580
|
+
It is associated to the submit form by id rather than nested in it, so the zone
|
|
1581
|
+
keeps the direct-child shape the dropzone's own state rules select on. */}
|
|
1582
|
+
<label class="dropzone-target">
|
|
1583
|
+
<svg class="icon" aria-hidden="true"><use href="#i-upload" /></svg>
|
|
1584
|
+
<span class="dropzone-lede">Drop an image, or choose one</span>
|
|
1585
|
+
<input
|
|
1586
|
+
class="dropzone-input"
|
|
1587
|
+
type="file"
|
|
1588
|
+
name="file"
|
|
1589
|
+
accept="image/*"
|
|
1590
|
+
aria-label="Drop an image, or choose one"
|
|
1591
|
+
form="image-upload"
|
|
1592
|
+
/>
|
|
1593
|
+
</label>
|
|
1144
1594
|
{images.length === 0 ? (
|
|
1145
1595
|
<p class="field-hint">No images yet.</p>
|
|
1146
1596
|
) : (
|
|
1147
|
-
<ul class="image-list">
|
|
1597
|
+
<ul class="dropzone-list image-list">
|
|
1148
1598
|
{images.map((item, index) =>
|
|
1149
1599
|
isRenderableImage(item) ? (
|
|
1150
1600
|
<li key={index}>
|
|
@@ -1169,6 +1619,7 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
|
|
|
1169
1619
|
</ul>
|
|
1170
1620
|
)}
|
|
1171
1621
|
<form
|
|
1622
|
+
id="image-upload"
|
|
1172
1623
|
class="image-upload"
|
|
1173
1624
|
method="post"
|
|
1174
1625
|
action={`/entries/${entry.id}/images`}
|
|
@@ -1178,258 +1629,138 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
|
|
|
1178
1629
|
hx-target="#editor-view"
|
|
1179
1630
|
hx-swap="outerHTML"
|
|
1180
1631
|
>
|
|
1181
|
-
<input class="input" type="file" name="file" accept="image/*" aria-label="Image file" />
|
|
1182
1632
|
<button class="btn btn-secondary btn-sm" type="submit" hx-disabled-elt="this">Add image</button>
|
|
1183
1633
|
</form>
|
|
1184
1634
|
</div>
|
|
1185
|
-
</
|
|
1635
|
+
</SupportSection>
|
|
1186
1636
|
);
|
|
1187
1637
|
}
|
|
1188
1638
|
|
|
1189
1639
|
/**
|
|
1190
1640
|
* The repo row's value: a recognized remote renders as its brand icon beside the bare owner/repo
|
|
1191
|
-
* path, both linking out to the host; an unrecognized one renders as the raw string as filed.
|
|
1641
|
+
* path, both linking out to the host; an unrecognized one renders as the raw string as filed. The
|
|
1642
|
+
* visible path is the short form, so the full remote rides along for assistive tech.
|
|
1192
1643
|
*/
|
|
1193
1644
|
function RepoValue({ repo }: { repo: string }) {
|
|
1194
1645
|
const link = parseRepoLink(repo);
|
|
1195
1646
|
if (link === null) return <>{repo}</>;
|
|
1196
1647
|
return (
|
|
1197
|
-
<a class="repo-link" href={link.href} target="_blank" rel="noopener noreferrer"
|
|
1648
|
+
<a class="repo-link" href={link.href} target="_blank" rel="noopener noreferrer">
|
|
1198
1649
|
<svg class="repo-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
1199
1650
|
<path d={REPO_ICON[link.provider]} />
|
|
1200
1651
|
</svg>
|
|
1201
1652
|
<span>{link.path}</span>
|
|
1653
|
+
<span class="sr-only">{repo}</span>
|
|
1202
1654
|
</a>
|
|
1203
1655
|
);
|
|
1204
1656
|
}
|
|
1205
1657
|
|
|
1206
1658
|
/**
|
|
1207
|
-
* The
|
|
1208
|
-
*
|
|
1209
|
-
* the
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
const context = parseContext(entry.context);
|
|
1213
|
-
return (
|
|
1214
|
-
<div class="card">
|
|
1215
|
-
<div class="card-header">
|
|
1216
|
-
<div class="card-title">Context</div>
|
|
1217
|
-
</div>
|
|
1218
|
-
<div class="card-content">
|
|
1219
|
-
<table class="info-table">
|
|
1220
|
-
<tr>
|
|
1221
|
-
<td>Anchor</td>
|
|
1222
|
-
<td>{entry.anchor_text}</td>
|
|
1223
|
-
</tr>
|
|
1224
|
-
{Object.entries(context).map(([key, value]) => (
|
|
1225
|
-
<tr key={key}>
|
|
1226
|
-
<td>{key}</td>
|
|
1227
|
-
<td>{typeof value === "string" ? value : JSON.stringify(value)}</td>
|
|
1228
|
-
</tr>
|
|
1229
|
-
))}
|
|
1230
|
-
<tr>
|
|
1231
|
-
<td>Repo</td>
|
|
1232
|
-
<td><RepoValue repo={entry.repo} /></td>
|
|
1233
|
-
</tr>
|
|
1234
|
-
<tr>
|
|
1235
|
-
<td>File</td>
|
|
1236
|
-
<td>{entry.file}</td>
|
|
1237
|
-
</tr>
|
|
1238
|
-
</table>
|
|
1239
|
-
<details class="context-secondary">
|
|
1240
|
-
<summary>More</summary>
|
|
1241
|
-
<table class="info-table">
|
|
1242
|
-
<tr>
|
|
1243
|
-
<td>File hash</td>
|
|
1244
|
-
<td>{entry.file_hash}</td>
|
|
1245
|
-
</tr>
|
|
1246
|
-
{entry.filed_by !== null ? (
|
|
1247
|
-
<tr>
|
|
1248
|
-
<td>Filed by</td>
|
|
1249
|
-
<td>{entry.filed_by}</td>
|
|
1250
|
-
</tr>
|
|
1251
|
-
) : null}
|
|
1252
|
-
{entry.stale_note !== null ? (
|
|
1253
|
-
<tr>
|
|
1254
|
-
<td>Stale note</td>
|
|
1255
|
-
<td>{entry.stale_note}</td>
|
|
1256
|
-
</tr>
|
|
1257
|
-
) : null}
|
|
1258
|
-
</table>
|
|
1259
|
-
</details>
|
|
1260
|
-
</div>
|
|
1261
|
-
</div>
|
|
1262
|
-
);
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
/**
|
|
1266
|
-
* The before-and-after of the entry's text, above the editor: what the anchored region reads now
|
|
1267
|
-
* (the anchor visually distinct) and what the entry's text will replace it with. Both sides
|
|
1268
|
-
* escape through JSX; the text is the stored bytes, shown as-is. The word-level marks come from
|
|
1269
|
-
* the server-side walk: words only the anchor has render red in the before card, words only the proposal
|
|
1270
|
-
* has render green in the after card, everything shared stays plain, so an identical pair renders
|
|
1271
|
-
* no marks at all.
|
|
1272
|
-
*/
|
|
1273
|
-
function DiffView({ entry }: { entry: EntryRow }) {
|
|
1274
|
-
const after = entry.human_text ?? entry.agent_draft;
|
|
1275
|
-
// No text at all means nothing to compare: a filed entry whose agent never drafted, or an
|
|
1276
|
-
// approved entry whose text was never set.
|
|
1277
|
-
if (after === null) return null;
|
|
1278
|
-
const diff = diffWords(entry.anchor_text, after);
|
|
1279
|
-
return (
|
|
1280
|
-
<div class="diff-grid">
|
|
1281
|
-
<div class="card">
|
|
1282
|
-
<div class="card-header">
|
|
1283
|
-
<div class="card-title">Before</div>
|
|
1284
|
-
</div>
|
|
1285
|
-
<pre class="diff-code">
|
|
1286
|
-
<span class="diff-line">{entry.anchor_before}</span>
|
|
1287
|
-
<span class="diff-line diff-anchor">
|
|
1288
|
-
{diff.before.map((token, index) =>
|
|
1289
|
-
token.kind === "removed" ? (
|
|
1290
|
-
<span class="diff-remove" key={index}>{token.text}</span>
|
|
1291
|
-
) : (
|
|
1292
|
-
token.text
|
|
1293
|
-
),
|
|
1294
|
-
)}
|
|
1295
|
-
</span>
|
|
1296
|
-
<span class="diff-line">{entry.anchor_after}</span>
|
|
1297
|
-
</pre>
|
|
1298
|
-
</div>
|
|
1299
|
-
<div class="card">
|
|
1300
|
-
<div class="card-header">
|
|
1301
|
-
<div class="card-title">After</div>
|
|
1302
|
-
</div>
|
|
1303
|
-
<pre class="diff-code">
|
|
1304
|
-
{diff.after.map((token, index) =>
|
|
1305
|
-
token.kind === "added" ? (
|
|
1306
|
-
<span class="diff-add" key={index}>{token.text}</span>
|
|
1307
|
-
) : (
|
|
1308
|
-
token.text
|
|
1309
|
-
),
|
|
1310
|
-
)}
|
|
1311
|
-
</pre>
|
|
1312
|
-
</div>
|
|
1313
|
-
</div>
|
|
1314
|
-
);
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
/**
|
|
1318
|
-
* The editor view: the diff above the editor grid, one swap node. A save changes the diff's
|
|
1319
|
-
* after-side, so the save form targets this wrapper and its response re-renders the whole view;
|
|
1320
|
-
* the transitions target only the region inside (they never change what the diff shows).
|
|
1659
|
+
* The editor view, one swap node: the change card beside the proposal pane, an optional notice
|
|
1660
|
+
* above them, then one support card holding the four sections across the full width. A terminal
|
|
1661
|
+
* entry drops the change card and lets the settled pane span the workspace. A save re-renders the
|
|
1662
|
+
* whole view because the marks follow the text and the status can move; the live diff swaps only
|
|
1663
|
+
* the current-text card.
|
|
1321
1664
|
*/
|
|
1322
1665
|
function EditorView({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
|
|
1323
|
-
const
|
|
1666
|
+
const entry = vm.entry;
|
|
1667
|
+
const terminal = entry.status === "applied" || entry.status === "rejected";
|
|
1668
|
+
const applied = entry.status === "applied";
|
|
1669
|
+
const compareText = state?.submittedText ?? entry.human_text ?? entry.agent_draft;
|
|
1324
1670
|
return (
|
|
1325
1671
|
<div id="editor-view">
|
|
1326
|
-
<
|
|
1672
|
+
<div id="editor-notice">
|
|
1673
|
+
{state?.refusal !== undefined ? (
|
|
1674
|
+
<DangerCallout title={state.refusal.title} body={state.refusal.body} />
|
|
1675
|
+
) : terminal ? (
|
|
1676
|
+
<InfoCallout
|
|
1677
|
+
title="Read only"
|
|
1678
|
+
body={
|
|
1679
|
+
applied
|
|
1680
|
+
? "This entry is already applied, so its text can't be changed here. If the anchor goes stale, the entry returns to approved and can be edited."
|
|
1681
|
+
: "This entry was rejected, so its text can't be changed. A rejected entry stays rejected; file a new entry if the passage still needs changing."
|
|
1682
|
+
}
|
|
1683
|
+
/>
|
|
1684
|
+
) : null}
|
|
1685
|
+
</div>
|
|
1327
1686
|
<div class="editor-grid">
|
|
1328
|
-
|
|
1329
|
-
|
|
1687
|
+
{terminal ? null : currentTextCard(entry, compareText)}
|
|
1688
|
+
<EditorRegion vm={vm} state={state} />
|
|
1689
|
+
<div class="editor-support">
|
|
1690
|
+
<div class="card editor-support-card">
|
|
1691
|
+
<div class="card-content">
|
|
1692
|
+
<DetailsSection entry={entry} />
|
|
1693
|
+
<NotesSection vm={vm} />
|
|
1694
|
+
<ImagesSection entry={entry} />
|
|
1695
|
+
<RevisionHistorySection revisions={vm.revisions} editable={!terminal} />
|
|
1696
|
+
</div>
|
|
1697
|
+
</div>
|
|
1330
1698
|
</div>
|
|
1331
|
-
<aside class="editor-rail">
|
|
1332
|
-
<ContextCard entry={vm.entry} />
|
|
1333
|
-
<ConstraintsCard constraints={vm.constraints} text={text} />
|
|
1334
|
-
<NotesCard vm={vm} />
|
|
1335
|
-
<ImagesCard entry={vm.entry} />
|
|
1336
|
-
</aside>
|
|
1337
1699
|
</div>
|
|
1338
1700
|
</div>
|
|
1339
1701
|
);
|
|
1340
1702
|
}
|
|
1341
1703
|
|
|
1342
|
-
/** The editor
|
|
1343
|
-
function EditorNav({ prevId, nextId }: { prevId: string | null; nextId: string | null }) {
|
|
1344
|
-
return (
|
|
1345
|
-
<nav class="editor-nav" aria-label="Entry navigation">
|
|
1346
|
-
<EditorNavButton side="prev" targetId={prevId} />
|
|
1347
|
-
<EditorNavButton side="next" targetId={nextId} />
|
|
1348
|
-
</nav>
|
|
1349
|
-
);
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
/** One rail: a chevron on a full-height strip, or an inert placeholder at the boundary. */
|
|
1353
|
-
function EditorNavButton({ side, targetId }: { side: "prev" | "next"; targetId: string | null }) {
|
|
1354
|
-
const label = side === "prev" ? "Previous entry" : "Next entry";
|
|
1355
|
-
const cls = `editor-nav-btn editor-nav-${side}`;
|
|
1356
|
-
const icon = (
|
|
1357
|
-
<svg aria-hidden="true"><use href={side === "prev" ? "#i-chevron-left" : "#i-chevron-right"} /></svg>
|
|
1358
|
-
);
|
|
1359
|
-
if (targetId === null) {
|
|
1360
|
-
return (
|
|
1361
|
-
<span class={`${cls} is-disabled`} aria-hidden="true">
|
|
1362
|
-
{icon}
|
|
1363
|
-
</span>
|
|
1364
|
-
);
|
|
1365
|
-
}
|
|
1366
|
-
return (
|
|
1367
|
-
<a class={cls} href={`/entries/${targetId}`} aria-label={label}>
|
|
1368
|
-
{icon}
|
|
1369
|
-
</a>
|
|
1370
|
-
);
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
/** The full editor page; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
|
|
1704
|
+
/** The full editor page; the mount wraps it in the doctype. `signedIn` is whether the sidebar offers sign-out. */
|
|
1374
1705
|
export function editorPage(vm: EditorViewModel, state: EditorState | undefined = undefined, signedIn = false): JSX.Element {
|
|
1375
1706
|
return (
|
|
1376
1707
|
<html lang="en" data-theme="dark">
|
|
1377
1708
|
<PageHead title={`Edit entry · ${NAME}`} />
|
|
1378
1709
|
<body>
|
|
1710
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
1379
1711
|
<IconSprite />
|
|
1380
|
-
<
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
<div class="label page-eyebrow">Entry review</div>
|
|
1386
|
-
<h1 class="page-title">Edit entry</h1>
|
|
1387
|
-
<p class="page-lede">Saving the text approves the entry and releases it to agents.</p>
|
|
1388
|
-
</div>
|
|
1389
|
-
</header>
|
|
1712
|
+
<Shell signedIn={signedIn} active="entries" draftCount={vm.draftCount}>
|
|
1713
|
+
{/* The workspace, header and support card share the same designed column, so the
|
|
1714
|
+
comparison and its rows keep a readable measure inside the wider shell track. */}
|
|
1715
|
+
<div class="editor-page">
|
|
1716
|
+
<EditorHeader vm={vm} />
|
|
1390
1717
|
<EditorView vm={vm} state={state} />
|
|
1391
|
-
<EditorNav prevId={vm.prevId} nextId={vm.nextId} />
|
|
1392
1718
|
{/* The one live region: announcements on constraint-state flips only, so a screen reader
|
|
1393
1719
|
is not flooded with a per-keystroke value. Lives outside the swap region. */}
|
|
1394
1720
|
<span id="editor-live" class="sr-only" aria-live="polite"></span>
|
|
1395
|
-
</
|
|
1396
|
-
</
|
|
1721
|
+
</div>
|
|
1722
|
+
</Shell>
|
|
1723
|
+
<div class="toast-stack" id="toast-stack"></div>
|
|
1397
1724
|
<PageModules />
|
|
1398
1725
|
</body>
|
|
1399
1726
|
</html>
|
|
1400
1727
|
);
|
|
1401
1728
|
}
|
|
1402
1729
|
|
|
1403
|
-
/** The editor region
|
|
1404
|
-
export function editorFragment(vm: EditorViewModel, state: EditorState | undefined = undefined): JSX.Element {
|
|
1405
|
-
return <EditorRegion vm={vm} state={state} />;
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
/** The whole editor view (diff + region + rail), for htmx save responses: a save changes the diff's after-side, so the swap must re-render it. */
|
|
1730
|
+
/** The whole editor view (columns + region), for htmx save and transition responses: either can move the status, so the swap re-renders the history card's editable state and the header tag with it. */
|
|
1409
1731
|
export function editorViewFragment(vm: EditorViewModel, state: EditorState | undefined = undefined): JSX.Element {
|
|
1410
|
-
return
|
|
1732
|
+
return (
|
|
1733
|
+
<>
|
|
1734
|
+
<DraftBadge count={vm.draftCount} oob />
|
|
1735
|
+
<EntryStatusTag status={vm.entry.status} oob />
|
|
1736
|
+
<EditorView vm={vm} state={state} />
|
|
1737
|
+
</>
|
|
1738
|
+
);
|
|
1411
1739
|
}
|
|
1412
1740
|
|
|
1413
|
-
/** The
|
|
1414
|
-
export function notFoundPage(
|
|
1741
|
+
/** The viewport-owning error page for an unknown entry id or route, with the way onward the page-not-found pattern demands. A route that never named an entry has no entry to miss. */
|
|
1742
|
+
export function notFoundPage(kind: "entry" | "route"): JSX.Element {
|
|
1743
|
+
const entry = kind === "entry";
|
|
1415
1744
|
return (
|
|
1416
1745
|
<html lang="en" data-theme="dark">
|
|
1417
|
-
<PageHead title={
|
|
1746
|
+
<PageHead title={`${entry ? "Entry" : "Page"} not found · ${NAME}`} />
|
|
1418
1747
|
<body>
|
|
1419
1748
|
<IconSprite />
|
|
1420
|
-
<
|
|
1421
|
-
<
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
<
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1749
|
+
<main class="page-state">
|
|
1750
|
+
<div class="page-state-gap">
|
|
1751
|
+
<div class="page-state-body">
|
|
1752
|
+
<p class="page-state-title">{entry ? "Entry not found" : "Page not found"}</p>
|
|
1753
|
+
<p class="page-state-text">
|
|
1754
|
+
{entry
|
|
1755
|
+
? "This entry doesn't exist. Return to the list to find it."
|
|
1756
|
+
: "Nothing lives at this address."}
|
|
1757
|
+
</p>
|
|
1758
|
+
<div class="page-state-actions">
|
|
1759
|
+
<a href="/" class="btn btn-fill">Back to entries</a>
|
|
1428
1760
|
</div>
|
|
1429
|
-
</
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
</div>
|
|
1761
|
+
</div>
|
|
1762
|
+
</div>
|
|
1763
|
+
</main>
|
|
1433
1764
|
<PageModules />
|
|
1434
1765
|
</body>
|
|
1435
1766
|
</html>
|
|
@@ -1439,7 +1770,7 @@ export function notFoundPage(signedIn = false): JSX.Element {
|
|
|
1439
1770
|
/** The htmx fragment for a save on an id that vanished mid-session: htmx will not swap a 4xx, so this is a 200 with the way onward in the editor region itself. */
|
|
1440
1771
|
export function entryGoneFragment(): JSX.Element {
|
|
1441
1772
|
return (
|
|
1442
|
-
<div id="editor-region">
|
|
1773
|
+
<div id="editor-region" tabindex={-1}>
|
|
1443
1774
|
<DangerCallout title="This entry no longer exists" body="Return to the list to find it." />
|
|
1444
1775
|
<a href="/" class="btn btn-secondary">Back to entries</a>
|
|
1445
1776
|
</div>
|
|
@@ -1464,6 +1795,8 @@ export type StyleGuideViewModel = {
|
|
|
1464
1795
|
readonly hasRow: boolean;
|
|
1465
1796
|
/** The stored section, or the empty defaults when there is no row yet. */
|
|
1466
1797
|
readonly section: StyleGuideInput;
|
|
1798
|
+
/** The sidebar's queue badge, read with the page's own store access. */
|
|
1799
|
+
readonly draftCount: number;
|
|
1467
1800
|
};
|
|
1468
1801
|
|
|
1469
1802
|
/** What the last save round settled into, rendered by the swap: the saved notice, the unknown-project notice, or the refusal with its message. The submitted form is kept so a refusal re-renders what the user typed. */
|
|
@@ -1487,6 +1820,7 @@ export function loadStyleGuide(store: Store, projectQuery: string | undefined):
|
|
|
1487
1820
|
unknown,
|
|
1488
1821
|
hasRow: section !== null,
|
|
1489
1822
|
section: section ?? { markdown: "", bannedWords: [], glossary: {} },
|
|
1823
|
+
draftCount: countEntriesByStatus(store).draft,
|
|
1490
1824
|
};
|
|
1491
1825
|
}
|
|
1492
1826
|
|
|
@@ -1513,45 +1847,29 @@ function sectionToForm(section: StyleGuideInput): StyleGuideForm {
|
|
|
1513
1847
|
};
|
|
1514
1848
|
}
|
|
1515
1849
|
|
|
1516
|
-
|
|
1517
|
-
return (
|
|
1518
|
-
<div class="callout callout-info">
|
|
1519
|
-
<div class="callout-icon">
|
|
1520
|
-
<svg aria-hidden="true"><use href="#i-info" /></svg>
|
|
1521
|
-
</div>
|
|
1522
|
-
<div class="callout-content">
|
|
1523
|
-
<div class="callout-title">{title}</div>
|
|
1524
|
-
{body !== undefined ? <div class="callout-body">{body}</div> : null}
|
|
1525
|
-
</div>
|
|
1526
|
-
</div>
|
|
1527
|
-
);
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
/** The project selector: the global section plus every known project, as links, with the current one marked. A link list is the whole mechanism; the section never auto-creates a project. */
|
|
1850
|
+
/** The project selector: the global section plus every known project, as underline tabs with the current one marked. A link list is the whole mechanism; the section never auto-creates a project. ui.js builds the sliding ink. */
|
|
1531
1851
|
function StyleGuideProjects({ vm }: { vm: StyleGuideViewModel }) {
|
|
1532
1852
|
const activeSlug = vm.unknown ? null : vm.projectSlug;
|
|
1533
1853
|
return (
|
|
1534
|
-
<nav class="style-guide-projects" aria-label="Style guide sections">
|
|
1535
|
-
<
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1854
|
+
<nav class="tabs-wrap style-guide-projects" data-scroll-fade aria-label="Style guide sections">
|
|
1855
|
+
<div class="tabs">
|
|
1856
|
+
<ProjectSectionLink href="/style-guide" label="Global" active={activeSlug === null} />
|
|
1857
|
+
{vm.projects.map((project) => (
|
|
1858
|
+
<ProjectSectionLink
|
|
1859
|
+
key={project.slug}
|
|
1860
|
+
href={`/style-guide?project=${project.slug}`}
|
|
1861
|
+
label={project.slug}
|
|
1862
|
+
active={activeSlug === project.slug}
|
|
1863
|
+
/>
|
|
1864
|
+
))}
|
|
1865
|
+
</div>
|
|
1544
1866
|
</nav>
|
|
1545
1867
|
);
|
|
1546
1868
|
}
|
|
1547
1869
|
|
|
1548
1870
|
function ProjectSectionLink({ href, label, active }: { href: string; label: string; active: boolean }) {
|
|
1549
1871
|
return (
|
|
1550
|
-
<a
|
|
1551
|
-
class={`btn btn-sm ${active ? "btn-primary" : "btn-secondary"}`}
|
|
1552
|
-
href={href}
|
|
1553
|
-
aria-current={active ? "page" : undefined}
|
|
1554
|
-
>
|
|
1872
|
+
<a class={`tab${active ? " active" : ""}`} href={href} aria-current={active ? "page" : undefined}>
|
|
1555
1873
|
{label}
|
|
1556
1874
|
</a>
|
|
1557
1875
|
);
|
|
@@ -1597,78 +1915,59 @@ function StyleGuideRegion({ vm, state }: { vm: StyleGuideViewModel; state: Style
|
|
|
1597
1915
|
body="Your save creates it."
|
|
1598
1916
|
/>
|
|
1599
1917
|
) : null}
|
|
1600
|
-
<
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
</div>
|
|
1614
|
-
<div class="card-content">
|
|
1615
|
-
<div class="field">
|
|
1616
|
-
<label class="field-label" for="style-guide-markdown">Markdown</label>
|
|
1617
|
-
<textarea class="input" id="style-guide-markdown" name="markdown" rows={10} aria-label="Guide markdown">
|
|
1618
|
-
{formValues.markdown}
|
|
1619
|
-
</textarea>
|
|
1620
|
-
{vm.hasRow && vm.section.markdown === "" ? <p class="field-hint">No markdown yet.</p> : null}
|
|
1621
|
-
</div>
|
|
1622
|
-
<div class="field">
|
|
1623
|
-
<label class="field-label" for="style-guide-banned">Banned words</label>
|
|
1624
|
-
<input class="input" id="style-guide-banned" name="banned_words" type="text" value={formValues.bannedWordsCsv} />
|
|
1625
|
-
<p class="field-hint">Comma-separated. Extra whitespace and duplicates are dropped.</p>
|
|
1626
|
-
{vm.hasRow && vm.section.bannedWords.length === 0 ? <p class="field-hint">No banned words yet.</p> : null}
|
|
1627
|
-
</div>
|
|
1628
|
-
<div class="field">
|
|
1629
|
-
<label class="field-label" for="style-guide-glossary">Glossary</label>
|
|
1630
|
-
<textarea class="input" id="style-guide-glossary" name="glossary" rows={6} aria-label="Glossary entries">
|
|
1631
|
-
{formValues.glossaryText}
|
|
1632
|
-
</textarea>
|
|
1633
|
-
<p class="field-hint">One key: value pair per line. A line without a colon is refused.</p>
|
|
1634
|
-
{vm.hasRow && Object.keys(vm.section.glossary).length === 0 ? (
|
|
1635
|
-
<p class="field-hint">No glossary entries yet.</p>
|
|
1636
|
-
) : null}
|
|
1637
|
-
</div>
|
|
1638
|
-
<div class="editor-actions">
|
|
1639
|
-
<button class="btn btn-primary" type="submit" hx-disabled-elt="this">Save guide</button>
|
|
1640
|
-
<span id="style-guide-loading" class="htmx-indicator" role="status">
|
|
1641
|
-
<span class="spinner" aria-hidden="true"></span>
|
|
1642
|
-
Saving guide
|
|
1643
|
-
</span>
|
|
1918
|
+
<div class="card style-guide-card">
|
|
1919
|
+
<form
|
|
1920
|
+
id="style-guide-form"
|
|
1921
|
+
method="post"
|
|
1922
|
+
action="/style-guide/save"
|
|
1923
|
+
hx-post="/style-guide/save"
|
|
1924
|
+
hx-target="#style-guide-region"
|
|
1925
|
+
hx-swap="outerHTML"
|
|
1926
|
+
hx-indicator="#style-guide-loading"
|
|
1927
|
+
>
|
|
1928
|
+
<input type="hidden" name="project" value={vm.projectSlug ?? ""} />
|
|
1929
|
+
<div class="card-header">
|
|
1930
|
+
<div class="card-title">{vm.projectSlug === null ? "Global guide" : `${vm.projectSlug} guide`}</div>
|
|
1644
1931
|
</div>
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1932
|
+
<div class="card-content style-guide-fields">
|
|
1933
|
+
<div class="field">
|
|
1934
|
+
<label class="field-label" for="style-guide-markdown">Markdown</label>
|
|
1935
|
+
<textarea class="input" id="style-guide-markdown" name="markdown" rows={10} aria-label="Guide markdown" data-autosize data-scroll-fade>
|
|
1936
|
+
{formValues.markdown}
|
|
1937
|
+
</textarea>
|
|
1938
|
+
{vm.hasRow && vm.section.markdown === "" ? <p class="field-hint">No markdown yet.</p> : null}
|
|
1939
|
+
</div>
|
|
1940
|
+
<div class="field">
|
|
1941
|
+
<label class="field-label" for="style-guide-banned">Banned words</label>
|
|
1942
|
+
<input class="input" id="style-guide-banned" name="banned_words" type="text" value={formValues.bannedWordsCsv} />
|
|
1943
|
+
<p class="field-hint">Comma-separated. Extra whitespace and duplicates are dropped.</p>
|
|
1944
|
+
{vm.hasRow && vm.section.bannedWords.length === 0 ? <p class="field-hint">No banned words yet.</p> : null}
|
|
1945
|
+
</div>
|
|
1946
|
+
<div class="field">
|
|
1947
|
+
<label class="field-label" for="style-guide-glossary">Glossary</label>
|
|
1948
|
+
<textarea class="input" id="style-guide-glossary" name="glossary" rows={6} aria-label="Glossary entries" data-autosize data-scroll-fade>
|
|
1949
|
+
{formValues.glossaryText}
|
|
1950
|
+
</textarea>
|
|
1951
|
+
<p class="field-hint">One key: value pair per line. A line without a colon is refused.</p>
|
|
1952
|
+
{vm.hasRow && Object.keys(vm.section.glossary).length === 0 ? (
|
|
1953
|
+
<p class="field-hint">No glossary entries yet.</p>
|
|
1954
|
+
) : null}
|
|
1955
|
+
</div>
|
|
1956
|
+
<div class="editor-actions">
|
|
1957
|
+
<button class="btn btn-fill" type="submit" hx-disabled-elt="this">Save guide</button>
|
|
1958
|
+
<span id="style-guide-loading" class="htmx-indicator" role="status">
|
|
1959
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
1960
|
+
Saving guide
|
|
1961
|
+
</span>
|
|
1962
|
+
</div>
|
|
1664
1963
|
</div>
|
|
1665
|
-
</
|
|
1964
|
+
</form>
|
|
1666
1965
|
</div>
|
|
1667
|
-
</
|
|
1966
|
+
</div>
|
|
1668
1967
|
);
|
|
1669
1968
|
}
|
|
1670
1969
|
|
|
1671
|
-
/** The full style guide page; the mount wraps it in the doctype. `signedIn` is whether the
|
|
1970
|
+
/** The full style guide page; the mount wraps it in the doctype. `signedIn` is whether the sidebar offers sign-out. */
|
|
1672
1971
|
export function styleGuidePage(
|
|
1673
1972
|
vm: StyleGuideViewModel,
|
|
1674
1973
|
state: StyleGuideEditorState | undefined = undefined,
|
|
@@ -1678,29 +1977,27 @@ export function styleGuidePage(
|
|
|
1678
1977
|
<html lang="en" data-theme="dark">
|
|
1679
1978
|
<PageHead title={`Style guide · ${NAME}`} />
|
|
1680
1979
|
<body>
|
|
1980
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
1681
1981
|
<IconSprite />
|
|
1682
|
-
<
|
|
1683
|
-
<
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
<
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
<StyleGuideErrorBoxTemplate />
|
|
1695
|
-
</main>
|
|
1696
|
-
</div>
|
|
1982
|
+
<Shell signedIn={signedIn} active="style-guide" draftCount={vm.draftCount}>
|
|
1983
|
+
<header class="page-header">
|
|
1984
|
+
<div class="page-header-body">
|
|
1985
|
+
<div class="label">Voice</div>
|
|
1986
|
+
<h1 class="page-title">Style guide</h1>
|
|
1987
|
+
<p class="section-lede">The voice agents read before drafting, starting with the global guide and adding each project's own rules below.</p>
|
|
1988
|
+
</div>
|
|
1989
|
+
</header>
|
|
1990
|
+
<StyleGuideProjects vm={vm} />
|
|
1991
|
+
<StyleGuideRegion vm={vm} state={state} />
|
|
1992
|
+
</Shell>
|
|
1993
|
+
<div class="toast-stack" id="toast-stack"></div>
|
|
1697
1994
|
<PageModules />
|
|
1698
1995
|
</body>
|
|
1699
1996
|
</html>
|
|
1700
1997
|
);
|
|
1701
1998
|
}
|
|
1702
1999
|
|
|
1703
|
-
/** The style guide region only, for htmx responses; the selector, the page shell, and the
|
|
2000
|
+
/** The style guide region only, for htmx responses; the selector, the page shell, and the toast stack stay put. */
|
|
1704
2001
|
export function styleGuideRegionFragment(vm: StyleGuideViewModel, state: StyleGuideEditorState | undefined = undefined): JSX.Element {
|
|
1705
2002
|
return <StyleGuideRegion vm={vm} state={state} />;
|
|
1706
2003
|
}
|