reviewzy 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -0
- package/README.md +29 -10
- package/package.json +1 -1
- package/src/config.ts +4 -0
- package/src/dashboard/mount.ts +118 -1
- package/src/dashboard/repo-link.ts +126 -0
- package/src/dashboard/static/components.css +15 -3
- package/src/dashboard/static/dashboard.css +172 -37
- package/src/dashboard/static/dashboard.js +141 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/views.tsx +503 -142
- package/src/dashboard/word-diff.ts +89 -0
- package/src/db/human-save.ts +112 -0
- package/src/db/migrations.ts +38 -0
- package/src/db/queries.ts +35 -12
- package/src/db/sweep.ts +2 -2
- package/src/mcp/file-entries.ts +53 -1
- package/src/mcp/list-entries.ts +41 -3
- package/src/mcp/mark-applied.ts +1 -1
- package/src/mcp/route.ts +6 -9
- package/src/mcp/server.ts +1 -1
- package/src/shim/daemon.ts +23 -16
- package/src/shim/main.ts +26 -5
package/src/dashboard/views.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/** @jsxImportSource hono/jsx */
|
|
2
2
|
import type { JSX } from "hono/jsx/jsx-runtime";
|
|
3
|
-
import { listRevisions, parseConstraints } from "../db/human-save.ts";
|
|
3
|
+
import { listRevisions, parseConstraints, parseEntryImages } from "../db/human-save.ts";
|
|
4
4
|
import type { Constraints, HumanSaveRefusal, RevisionRow, TransitionRefusal } from "../db/human-save.ts";
|
|
5
5
|
import {
|
|
6
|
+
adjacentEntryIds,
|
|
6
7
|
countEntries,
|
|
7
8
|
listEntries,
|
|
8
9
|
listProjects,
|
|
@@ -12,6 +13,8 @@ import type { EntryRow, EntryStatus } from "../db/queries.ts";
|
|
|
12
13
|
import { readStyleGuideSection } from "../db/style-guide.ts";
|
|
13
14
|
import type { StyleGuideForm, StyleGuideFormRefusal, StyleGuideInput } from "../db/style-guide.ts";
|
|
14
15
|
import type { Store } from "../db/store.ts";
|
|
16
|
+
import { parseRepoLink, REPO_ICON } from "./repo-link.ts";
|
|
17
|
+
import { diffWords } from "./word-diff.ts";
|
|
15
18
|
import { NAME, VERSION } from "../version.ts";
|
|
16
19
|
|
|
17
20
|
/**
|
|
@@ -30,6 +33,9 @@ const PAGE_SIZE = 200;
|
|
|
30
33
|
|
|
31
34
|
const STATUSES: readonly EntryStatus[] = ["draft", "approved", "applied", "rejected"];
|
|
32
35
|
|
|
36
|
+
/** The default Active view: the two statuses that still need a human. */
|
|
37
|
+
const ACTIVE_STATUSES: readonly EntryStatus[] = ["draft", "approved"];
|
|
38
|
+
|
|
33
39
|
function isEntryStatus(value: string): value is EntryStatus {
|
|
34
40
|
return (STATUSES as readonly string[]).includes(value);
|
|
35
41
|
}
|
|
@@ -48,7 +54,8 @@ const STATUS_LABEL: Record<EntryStatus, string> = {
|
|
|
48
54
|
rejected: "Rejected",
|
|
49
55
|
};
|
|
50
56
|
|
|
51
|
-
type
|
|
57
|
+
type ProjectBatch = { id: string; entries: EntryRow[] };
|
|
58
|
+
type ProjectGroup = { slug: string; batches: ProjectBatch[] };
|
|
52
59
|
|
|
53
60
|
export type ListViewModel = {
|
|
54
61
|
readonly total: number;
|
|
@@ -60,6 +67,8 @@ export type ListViewModel = {
|
|
|
60
67
|
readonly status: string;
|
|
61
68
|
readonly project: string;
|
|
62
69
|
readonly filtersActive: boolean;
|
|
70
|
+
/** Whether the default Active view is what hides every match: a filter found rows, but all of them are applied or rejected. */
|
|
71
|
+
readonly hiddenByActiveView: boolean;
|
|
63
72
|
};
|
|
64
73
|
|
|
65
74
|
/** Runs the keyset walk to exhaustion: the mcp tool's own list query, page size 200, no pagination UI. */
|
|
@@ -85,9 +94,11 @@ function allEntries(
|
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
/**
|
|
88
|
-
* Groups the filtered rows by project (slug order) then batch
|
|
89
|
-
*
|
|
90
|
-
*
|
|
97
|
+
* Groups the filtered rows by project (slug order) then batch; within a batch the rows keep the
|
|
98
|
+
* list query's ascending id order. Within a project, batches split into two partitions: any batch
|
|
99
|
+
* holding a draft or approved entry is actionable and leads, terminal-only batches (applied and
|
|
100
|
+
* rejected) follow. Each partition sorts newest batch first, with a deterministic order for
|
|
101
|
+
* batches minted in the same millisecond, so the same filter always renders the same page.
|
|
91
102
|
*/
|
|
92
103
|
function groupRows(rows: readonly EntryRow[], slugOf: Map<string, string>): ProjectGroup[] {
|
|
93
104
|
const byProject = new Map<string, EntryRow[]>();
|
|
@@ -101,24 +112,65 @@ function groupRows(rows: readonly EntryRow[], slugOf: Map<string, string>): Proj
|
|
|
101
112
|
for (const row of projectRows) {
|
|
102
113
|
byBatch.set(row.batch_id, [...(byBatch.get(row.batch_id) ?? []), row]);
|
|
103
114
|
}
|
|
104
|
-
const batches = [...byBatch.keys()].
|
|
105
|
-
|
|
115
|
+
const batches = [...byBatch.keys()].map((batchId) => ({ id: batchId, entries: byBatch.get(batchId)! }));
|
|
116
|
+
const actionable = batches.filter(isActionableBatch).sort(newestBatchFirst);
|
|
117
|
+
const terminal = batches.filter((batch) => !isActionableBatch(batch)).sort(newestBatchFirst);
|
|
118
|
+
return { slug: slugOf.get(projectId) ?? projectId, batches: [...actionable, ...terminal] };
|
|
106
119
|
});
|
|
107
120
|
}
|
|
108
121
|
|
|
122
|
+
/** A batch is actionable when any of its entries still needs a human: draft or approved. */
|
|
123
|
+
function isActionableBatch(batch: ProjectBatch): boolean {
|
|
124
|
+
return batch.entries.some((entry) => entry.status === "draft" || entry.status === "approved");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Code-unit descending comparison: ulids are fixed-length Crockford base32, so this equals time order. */
|
|
128
|
+
function compareDesc(a: string, b: string): number {
|
|
129
|
+
return a < b ? 1 : a > b ? -1 : 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The newest `created_at` in a batch, walked: a batch can hold thousands of entries, and a spread would overflow the call stack. */
|
|
133
|
+
function maxCreatedAt(batch: ProjectBatch): number {
|
|
134
|
+
let max = 0;
|
|
135
|
+
for (const entry of batch.entries) {
|
|
136
|
+
if (entry.created_at > max) max = entry.created_at;
|
|
137
|
+
}
|
|
138
|
+
return max;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Newest batch first. Batch ulids share a millisecond timestamp prefix, so a descending id is
|
|
143
|
+
* newest-first; batches minted in the same millisecond tie on that prefix, and the batch's
|
|
144
|
+
* newest `created_at` breaks the tie before the id itself, so a same-ms burst still renders in
|
|
145
|
+
* one fixed order.
|
|
146
|
+
*/
|
|
147
|
+
function newestBatchFirst(a: ProjectBatch, b: ProjectBatch): number {
|
|
148
|
+
const timeCmp = compareDesc(a.id.slice(0, 10), b.id.slice(0, 10));
|
|
149
|
+
if (timeCmp !== 0) return timeCmp;
|
|
150
|
+
const aCreated = maxCreatedAt(a);
|
|
151
|
+
const bCreated = maxCreatedAt(b);
|
|
152
|
+
if (aCreated !== bCreated) return bCreated - aCreated;
|
|
153
|
+
return compareDesc(a.id, b.id);
|
|
154
|
+
}
|
|
155
|
+
|
|
109
156
|
function loadList(store: Store, params: ListParams): ListViewModel {
|
|
110
157
|
const q = params.q;
|
|
111
158
|
const qActive = q !== undefined && q.trim() !== "";
|
|
112
159
|
const statusRaw = params.status;
|
|
113
160
|
const statusActive = statusRaw !== undefined && statusRaw !== "";
|
|
114
|
-
|
|
161
|
+
// `all` is the explicit "every status" filter value; the default (no status param, or an empty
|
|
162
|
+
// one) is the Active view of draft + approved only, which is not a filter and never renders the
|
|
163
|
+
// partial header. Both query every status; the Active view narrows the walked rows afterward.
|
|
164
|
+
const allStatuses = statusActive && statusRaw === "all";
|
|
165
|
+
const status = statusActive && !allStatuses && isEntryStatus(statusRaw) ? statusRaw : undefined;
|
|
115
166
|
const projectRaw = params.project;
|
|
116
167
|
const projectActive = projectRaw !== undefined && projectRaw !== "";
|
|
117
168
|
const projectId = projectActive ? (projectIdBySlug(store, projectRaw) ?? undefined) : undefined;
|
|
118
169
|
|
|
119
170
|
// A status or project value no filter can match is a settled no-match, not a page error.
|
|
120
|
-
const impossible = (statusActive && status === undefined) || (projectActive && projectId === undefined);
|
|
121
|
-
const
|
|
171
|
+
const impossible = (statusActive && !allStatuses && status === undefined) || (projectActive && projectId === undefined);
|
|
172
|
+
const walked = impossible ? [] : allEntries(store, { q: qActive ? q : undefined, status, projectId });
|
|
173
|
+
const rows = statusActive ? walked : walked.filter((row) => ACTIVE_STATUSES.includes(row.status));
|
|
122
174
|
|
|
123
175
|
const projects = listProjects(store);
|
|
124
176
|
const slugOf = new Map(projects.map((project) => [project.id, project.slug]));
|
|
@@ -131,27 +183,31 @@ function loadList(store: Store, params: ListParams): ListViewModel {
|
|
|
131
183
|
status: statusRaw ?? "",
|
|
132
184
|
project: projectRaw ?? "",
|
|
133
185
|
filtersActive: qActive || statusActive || projectActive,
|
|
186
|
+
hiddenByActiveView: !statusActive && walked.length > 0 && rows.length === 0,
|
|
134
187
|
};
|
|
135
188
|
}
|
|
136
189
|
|
|
137
|
-
/** The
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
190
|
+
/** The basename of a repo file path (the segment after the last slash), the dim per-row prefix. */
|
|
191
|
+
function fileBasename(path: string): string {
|
|
192
|
+
return path.split(/[/\\]/).pop() ?? path;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The entry's display title: the agent-supplied `title` when it is non-empty, else the anchor text
|
|
197
|
+
* (the string being changed), else the file basename for a foreign row carrying neither. The title
|
|
198
|
+
* is what the list and the editor lead with in place of the file path.
|
|
199
|
+
*/
|
|
200
|
+
function entryTitle(entry: EntryRow): string {
|
|
201
|
+
const title = entry.title?.trim();
|
|
202
|
+
if (title !== undefined && title !== "") return title;
|
|
203
|
+
const anchor = entry.anchor_text.trim();
|
|
204
|
+
if (anchor !== "") return anchor;
|
|
205
|
+
return fileBasename(entry.file);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The batch ulid shortened to head+tail, enough to name each batch's select-all checkbox distinctly for a screen reader. */
|
|
209
|
+
function shortUlid(id: string): string {
|
|
210
|
+
return `${id.slice(0, 6)}…${id.slice(-6)}`;
|
|
155
211
|
}
|
|
156
212
|
|
|
157
213
|
/** The section the current page belongs to, marking the matching navbar link. */
|
|
@@ -178,12 +234,11 @@ function Navbar({ signedIn, active }: { signedIn: boolean; active: NavSection })
|
|
|
178
234
|
);
|
|
179
235
|
}
|
|
180
236
|
|
|
181
|
-
function PageHeader() {
|
|
237
|
+
function PageHeader({ total }: { total: number }) {
|
|
182
238
|
return (
|
|
183
239
|
<header class="page-header">
|
|
184
|
-
<div class="label page-eyebrow">Review queue</div>
|
|
185
240
|
<h1 class="page-title">Entries</h1>
|
|
186
|
-
<
|
|
241
|
+
<span class="page-count">{total} {total === 1 ? "entry" : "entries"}</span>
|
|
187
242
|
</header>
|
|
188
243
|
);
|
|
189
244
|
}
|
|
@@ -214,7 +269,8 @@ function FilterForm({ vm }: { vm: ListViewModel }) {
|
|
|
214
269
|
<div class="field">
|
|
215
270
|
<label class="field-label" for="status">Status</label>
|
|
216
271
|
<select class="input" id="status" name="status">
|
|
217
|
-
<option value="">
|
|
272
|
+
<option value="" selected={vm.status === ""}>Active</option>
|
|
273
|
+
<option value="all" selected={vm.status === "all"}>All statuses</option>
|
|
218
274
|
<option value="draft" selected={vm.status === "draft"}>Draft</option>
|
|
219
275
|
<option value="approved" selected={vm.status === "approved"}>Approved</option>
|
|
220
276
|
<option value="applied" selected={vm.status === "applied"}>Applied</option>
|
|
@@ -232,7 +288,9 @@ function FilterForm({ vm }: { vm: ListViewModel }) {
|
|
|
232
288
|
))}
|
|
233
289
|
</select>
|
|
234
290
|
</div>
|
|
235
|
-
<
|
|
291
|
+
<noscript>
|
|
292
|
+
<button class="btn btn-primary" type="submit">Apply filters</button>
|
|
293
|
+
</noscript>
|
|
236
294
|
</form>
|
|
237
295
|
);
|
|
238
296
|
}
|
|
@@ -294,11 +352,24 @@ function NoticeCallout({ notice }: { notice: ListNotice }) {
|
|
|
294
352
|
function ListRegion({ vm, notice }: { vm: ListViewModel; notice: ListNotice | undefined }) {
|
|
295
353
|
if (vm.shown === 0) {
|
|
296
354
|
// Zero entries at all is "no entries yet" even under a filter: nothing exists to filter, and
|
|
297
|
-
// the empty state explains how entries get here.
|
|
355
|
+
// the empty state explains how entries get here. A store with entries but no matches is either
|
|
356
|
+
// a filter miss or the default Active view over a store full of applied and rejected entries.
|
|
298
357
|
return (
|
|
299
358
|
<>
|
|
300
359
|
{notice !== undefined ? <NoticeCallout notice={notice} /> : null}
|
|
301
|
-
{vm.total === 0 ?
|
|
360
|
+
{vm.total === 0 ? (
|
|
361
|
+
<EmptyAll />
|
|
362
|
+
) : vm.filtersActive ? (
|
|
363
|
+
<EmptyMatch
|
|
364
|
+
revealAllHref={
|
|
365
|
+
vm.hiddenByActiveView
|
|
366
|
+
? `/?q=${encodeURIComponent(vm.q)}&project=${encodeURIComponent(vm.project)}&status=all`
|
|
367
|
+
: undefined
|
|
368
|
+
}
|
|
369
|
+
/>
|
|
370
|
+
) : (
|
|
371
|
+
<EmptyActive />
|
|
372
|
+
)}
|
|
302
373
|
</>
|
|
303
374
|
);
|
|
304
375
|
}
|
|
@@ -337,7 +408,13 @@ function EmptyAll() {
|
|
|
337
408
|
);
|
|
338
409
|
}
|
|
339
410
|
|
|
340
|
-
|
|
411
|
+
/**
|
|
412
|
+
* The filter-miss state, with a way onward. When the default Active view is what hides the
|
|
413
|
+
* matches (a search that only hits applied or rejected rows), the way onward is the all-statuses
|
|
414
|
+
* view under the same filters — clearing the filters alone would land back on the Active view
|
|
415
|
+
* and still show nothing.
|
|
416
|
+
*/
|
|
417
|
+
function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
|
|
341
418
|
return (
|
|
342
419
|
<div class="card empty-state">
|
|
343
420
|
<div class="empty-icon" aria-hidden="true">
|
|
@@ -348,7 +425,29 @@ function EmptyMatch() {
|
|
|
348
425
|
</div>
|
|
349
426
|
<h3>No entries match</h3>
|
|
350
427
|
<p>Try different search terms, or clear the filters.</p>
|
|
351
|
-
<
|
|
428
|
+
{revealAllHref !== undefined ? <p>Applied and rejected entries are hidden from this view.</p> : null}
|
|
429
|
+
<div class="empty-actions">
|
|
430
|
+
<a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
|
|
431
|
+
{revealAllHref !== undefined ? (
|
|
432
|
+
<a href={revealAllHref} class="btn btn-secondary btn-sm">Show all statuses</a>
|
|
433
|
+
) : null}
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The default Active view over a store whose entries are all applied or rejected: nothing to do, no filters to clear. */
|
|
440
|
+
function EmptyActive() {
|
|
441
|
+
return (
|
|
442
|
+
<div class="card empty-state">
|
|
443
|
+
<div class="empty-icon" aria-hidden="true">
|
|
444
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
445
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
446
|
+
<path d="M5 8l2 2 4-4" />
|
|
447
|
+
</svg>
|
|
448
|
+
</div>
|
|
449
|
+
<h3>Nothing to review</h3>
|
|
450
|
+
<p>Every filed entry is applied or rejected. New drafts appear here as agents file them.</p>
|
|
352
451
|
</div>
|
|
353
452
|
);
|
|
354
453
|
}
|
|
@@ -395,51 +494,104 @@ function ProjectGroupView({ group }: { group: ProjectGroup }) {
|
|
|
395
494
|
<span class="tag tag-data">
|
|
396
495
|
{count} {count === 1 ? "entry" : "entries"}
|
|
397
496
|
</span>
|
|
497
|
+
{/* Every section and batch ships open; dashboard.js collapses the ones this viewer
|
|
498
|
+
collapsed before (localStorage) and keeps the buttons inert without JS. */}
|
|
499
|
+
<button
|
|
500
|
+
type="button"
|
|
501
|
+
class="btn btn-icon collapse-toggle"
|
|
502
|
+
aria-expanded="true"
|
|
503
|
+
aria-controls={`project-batches-${group.slug}`}
|
|
504
|
+
aria-label={`Toggle ${group.slug} section`}
|
|
505
|
+
data-collapse-toggle
|
|
506
|
+
data-collapse-target={`project-batches-${group.slug}`}
|
|
507
|
+
data-collapse-key={`project:${group.slug}`}
|
|
508
|
+
>
|
|
509
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
510
|
+
<path d="M3 6l5 5 5-5" />
|
|
511
|
+
</svg>
|
|
512
|
+
</button>
|
|
398
513
|
</header>
|
|
399
|
-
{group.batches
|
|
400
|
-
|
|
401
|
-
<
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
</div>
|
|
405
|
-
<div class="table-wrap">
|
|
406
|
-
<table>
|
|
407
|
-
<thead>
|
|
408
|
-
<tr>
|
|
409
|
-
<th class="cell-select">Select</th>
|
|
410
|
-
<th>File</th>
|
|
411
|
-
<th>Anchor</th>
|
|
412
|
-
<th>Text</th>
|
|
413
|
-
<th>Status</th>
|
|
414
|
-
<th>Filed by</th>
|
|
415
|
-
<th>Constraints</th>
|
|
416
|
-
<th>Actions</th>
|
|
417
|
-
</tr>
|
|
418
|
-
</thead>
|
|
419
|
-
<tbody>
|
|
420
|
-
{batch.entries.map((entry) => (
|
|
421
|
-
<EntryRowView key={entry.id} entry={entry} />
|
|
422
|
-
))}
|
|
423
|
-
</tbody>
|
|
424
|
-
</table>
|
|
425
|
-
</div>
|
|
426
|
-
</div>
|
|
427
|
-
))}
|
|
514
|
+
<div id={`project-batches-${group.slug}`} class="project-batches">
|
|
515
|
+
{group.batches.map((batch) => (
|
|
516
|
+
<BatchView key={batch.id} batch={batch} />
|
|
517
|
+
))}
|
|
518
|
+
</div>
|
|
428
519
|
</section>
|
|
429
520
|
);
|
|
430
521
|
}
|
|
431
522
|
|
|
523
|
+
function BatchView({ batch }: { batch: ProjectBatch }) {
|
|
524
|
+
// A batch names its filer in the header only when every entry agrees on one non-null
|
|
525
|
+
// value; a disagreement leaves the header filer-less and each row names its own inline.
|
|
526
|
+
const filedBy = batch.entries.map((entry) => entry.filed_by);
|
|
527
|
+
const allAgree = filedBy.every((filer) => filer === filedBy[0]);
|
|
528
|
+
const singleFiler = allAgree && filedBy[0] !== null ? filedBy[0] : null;
|
|
529
|
+
const mixedFilers = !allAgree && filedBy.some((filer) => filer !== null);
|
|
530
|
+
return (
|
|
531
|
+
<div class="batch">
|
|
532
|
+
<div class="batch-header">
|
|
533
|
+
{/* Select-all toggles only this batch's draft rows. No `name`, so it never submits as
|
|
534
|
+
an entry id; dashboard.js derives checked/indeterminate from the draft checkboxes. */}
|
|
535
|
+
<input type="checkbox" class="select-all" aria-label={`Select all drafts in batch ${shortUlid(batch.id)}`} />
|
|
536
|
+
{singleFiler !== null ? <span class="batch-meta">{singleFiler}</span> : null}
|
|
537
|
+
<button
|
|
538
|
+
type="button"
|
|
539
|
+
class="btn btn-icon collapse-toggle"
|
|
540
|
+
aria-expanded="true"
|
|
541
|
+
aria-controls={`batch-table-${batch.id}`}
|
|
542
|
+
aria-label={`Toggle batch ${shortUlid(batch.id)}`}
|
|
543
|
+
data-collapse-toggle
|
|
544
|
+
data-collapse-target={`batch-table-${batch.id}`}
|
|
545
|
+
data-collapse-key={`batch:${batch.id}`}
|
|
546
|
+
>
|
|
547
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
548
|
+
<path d="M3 6l5 5 5-5" />
|
|
549
|
+
</svg>
|
|
550
|
+
</button>
|
|
551
|
+
</div>
|
|
552
|
+
<div class="table-wrap" id={`batch-table-${batch.id}`}>
|
|
553
|
+
<table>
|
|
554
|
+
<colgroup>
|
|
555
|
+
<col class="col-select" />
|
|
556
|
+
<col />
|
|
557
|
+
<col class="col-status" />
|
|
558
|
+
<col class="col-file" />
|
|
559
|
+
<col class="col-actions" />
|
|
560
|
+
</colgroup>
|
|
561
|
+
<thead>
|
|
562
|
+
<tr>
|
|
563
|
+
{/* The column header stays in the accessibility tree via the sr-only span; the
|
|
564
|
+
checkbox column itself is too narrow for a visible label. */}
|
|
565
|
+
<th><span class="visually-hidden">Select</span></th>
|
|
566
|
+
<th>Title</th>
|
|
567
|
+
<th>Status</th>
|
|
568
|
+
<th>File</th>
|
|
569
|
+
<th>Actions</th>
|
|
570
|
+
</tr>
|
|
571
|
+
</thead>
|
|
572
|
+
<tbody>
|
|
573
|
+
{batch.entries.map((entry) => (
|
|
574
|
+
<EntryRowView key={entry.id} entry={entry} filerInline={mixedFilers} />
|
|
575
|
+
))}
|
|
576
|
+
</tbody>
|
|
577
|
+
</table>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
432
583
|
/**
|
|
433
584
|
* One row. The checkbox names the entry for the batch form; it exists only on drafts, because an
|
|
434
585
|
* approve on any other status always refuses and a checkbox that can only refuse is noise (races
|
|
435
|
-
* are still reported per entry). The
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* the
|
|
586
|
+
* are still reported per entry). The status chip sits beside the title; the three icon action slots
|
|
587
|
+
* always render, inapplicable ones disabled, so a double-click's second click lands on the disabled
|
|
588
|
+
* slot rather than a shifted action. Approve and reject target the list region and include the
|
|
589
|
+
* batch form's hidden filters, so an action round re-renders the region under the same filter; the
|
|
590
|
+
* editor link is always present. When the batch holds mixed filers, the row also names its own
|
|
591
|
+
* filer beside the title.
|
|
439
592
|
*/
|
|
440
|
-
function EntryRowView({ entry }: { entry: EntryRow }) {
|
|
441
|
-
const
|
|
442
|
-
const constraints = constraintSummary(entry.constraints);
|
|
593
|
+
function EntryRowView({ entry, filerInline }: { entry: EntryRow; filerInline: boolean }) {
|
|
594
|
+
const title = entryTitle(entry);
|
|
443
595
|
const approve = entry.status === "draft";
|
|
444
596
|
const reject = entry.status === "draft" || entry.status === "approved";
|
|
445
597
|
return (
|
|
@@ -449,60 +601,77 @@ function EntryRowView({ entry }: { entry: EntryRow }) {
|
|
|
449
601
|
<input type="checkbox" name="id" value={entry.id} aria-label={`Select ${entry.file}`} />
|
|
450
602
|
) : null}
|
|
451
603
|
</td>
|
|
452
|
-
<td class="cell-file
|
|
453
|
-
<
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
</td>
|
|
458
|
-
<td class="cell-text">
|
|
459
|
-
<span class="entry-text" title={text ?? ""}>{text}</span>
|
|
460
|
-
</td>
|
|
461
|
-
<td>
|
|
462
|
-
<span class={`tag ${STATUS_TAG[entry.status]}`}>
|
|
463
|
-
<span class="tag-dot"></span>
|
|
464
|
-
{STATUS_LABEL[entry.status]}
|
|
465
|
-
</span>
|
|
604
|
+
<td class="cell-title" title={`${entry.file} — ${entry.anchor_text}`}>
|
|
605
|
+
<div class="entry-head">
|
|
606
|
+
<span class="entry-title">{title}</span>
|
|
607
|
+
{filerInline && entry.filed_by !== null ? <span class="entry-filer"> · {entry.filed_by}</span> : null}
|
|
608
|
+
</div>
|
|
466
609
|
</td>
|
|
467
|
-
<td class="cell-
|
|
468
|
-
|
|
469
|
-
{constraints.length > 0 ? <span class="constraint-summary">{constraints.join(" · ")}</span> : null}
|
|
610
|
+
<td class="cell-status">
|
|
611
|
+
<span class={`tag ${STATUS_TAG[entry.status]}`}>{STATUS_LABEL[entry.status]}</span>
|
|
470
612
|
</td>
|
|
613
|
+
<td class="cell-file" title={entry.file}>{entry.file}</td>
|
|
471
614
|
<td class="cell-actions">
|
|
472
615
|
{approve ? (
|
|
473
616
|
<button
|
|
474
617
|
type="button"
|
|
475
|
-
class="btn btn-
|
|
618
|
+
class="btn btn-icon"
|
|
619
|
+
style="color: var(--success)"
|
|
620
|
+
aria-label={`Approve ${entry.file}`}
|
|
621
|
+
title="Approve"
|
|
476
622
|
hx-post={`/entries/${entry.id}/approve`}
|
|
477
623
|
hx-target="#entries-list"
|
|
478
624
|
hx-swap="innerHTML"
|
|
479
625
|
hx-indicator="#entries-loading"
|
|
480
626
|
hx-disabled-elt="this"
|
|
481
627
|
>
|
|
482
|
-
|
|
628
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
629
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
630
|
+
<path d="M5 8l2 2 4-4" />
|
|
631
|
+
</svg>
|
|
483
632
|
</button>
|
|
484
|
-
) :
|
|
485
|
-
// The approve slot stays occupied
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
633
|
+
) : (
|
|
634
|
+
// The approve slot stays occupied on every non-draft row: a double-click's second click
|
|
635
|
+
// lands on the disabled button (pointer-events none) rather than the next slot.
|
|
636
|
+
<button type="button" class="btn btn-icon" style="color: var(--success)" aria-label={`Approve ${entry.file}`} title="Approve" disabled>
|
|
637
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
638
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
639
|
+
<path d="M5 8l2 2 4-4" />
|
|
640
|
+
</svg>
|
|
490
641
|
</button>
|
|
491
|
-
)
|
|
642
|
+
)}
|
|
492
643
|
{reject ? (
|
|
493
644
|
<button
|
|
494
645
|
type="button"
|
|
495
|
-
class="btn btn-
|
|
646
|
+
class="btn btn-icon"
|
|
647
|
+
style="color: var(--danger)"
|
|
648
|
+
aria-label={`Reject ${entry.file}`}
|
|
649
|
+
title="Reject"
|
|
496
650
|
hx-post={`/entries/${entry.id}/reject`}
|
|
497
651
|
hx-target="#entries-list"
|
|
498
652
|
hx-swap="innerHTML"
|
|
499
653
|
hx-indicator="#entries-loading"
|
|
500
654
|
hx-disabled-elt="this"
|
|
501
655
|
>
|
|
502
|
-
|
|
656
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
657
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
658
|
+
<path d="M10 6L6 10M6 6l4 4" />
|
|
659
|
+
</svg>
|
|
503
660
|
</button>
|
|
504
|
-
) :
|
|
505
|
-
|
|
661
|
+
) : (
|
|
662
|
+
// The reject slot stays occupied on applied and rejected rows for the same guard.
|
|
663
|
+
<button type="button" class="btn btn-icon" style="color: var(--danger)" aria-label={`Reject ${entry.file}`} title="Reject" disabled>
|
|
664
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
665
|
+
<circle cx="8" cy="8" r="6.5" />
|
|
666
|
+
<path d="M10 6L6 10M6 6l4 4" />
|
|
667
|
+
</svg>
|
|
668
|
+
</button>
|
|
669
|
+
)}
|
|
670
|
+
<a class="btn btn-icon" href={`/entries/${entry.id}`} aria-label={`Edit ${entry.file}`} title="Edit">
|
|
671
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
672
|
+
<path d="M11.5 2.5l2 2L5 13H3v-2z" />
|
|
673
|
+
</svg>
|
|
674
|
+
</a>
|
|
506
675
|
</td>
|
|
507
676
|
</tr>
|
|
508
677
|
);
|
|
@@ -557,7 +726,7 @@ export function dashboardPage(
|
|
|
557
726
|
<div class="app-shell">
|
|
558
727
|
<Navbar signedIn={signedIn} active="entries" />
|
|
559
728
|
<main class="main">
|
|
560
|
-
<PageHeader />
|
|
729
|
+
<PageHeader total={vm.total} />
|
|
561
730
|
<FilterForm vm={vm} />
|
|
562
731
|
<div id="entries">
|
|
563
732
|
<div id="entries-loading" class="htmx-indicator" role="status">
|
|
@@ -643,6 +812,9 @@ export type EditorViewModel = {
|
|
|
643
812
|
readonly entry: EntryRow;
|
|
644
813
|
readonly revisions: readonly RevisionRow[];
|
|
645
814
|
readonly constraints: Constraints;
|
|
815
|
+
/** The entry immediately before/after in the list's ascending-id walk, or null at the boundary. */
|
|
816
|
+
readonly prevId: string | null;
|
|
817
|
+
readonly nextId: string | null;
|
|
646
818
|
};
|
|
647
819
|
|
|
648
820
|
/**
|
|
@@ -665,7 +837,8 @@ export function getEntry(store: Store, id: string): EntryRow | undefined {
|
|
|
665
837
|
export function loadEditor(store: Store, id: string): EditorViewModel | null {
|
|
666
838
|
const entry = getEntry(store, id);
|
|
667
839
|
if (entry === undefined) return null;
|
|
668
|
-
|
|
840
|
+
const { prevId, nextId } = adjacentEntryIds(store, id);
|
|
841
|
+
return { entry, revisions: listRevisions(store, id), constraints: parseConstraints(entry.constraints), prevId, nextId };
|
|
669
842
|
}
|
|
670
843
|
|
|
671
844
|
/** The tolerant parse for `entries.context`, mirroring `parseConstraints`: foreign rows may hold malformed JSON, and unparseable means no context rows, never a 500. */
|
|
@@ -695,7 +868,7 @@ export function formatSaveRefusal(refusal: Exclude<HumanSaveRefusal, { kind: "un
|
|
|
695
868
|
case "status":
|
|
696
869
|
return refusal.status === "applied"
|
|
697
870
|
? "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."
|
|
698
|
-
: "This entry was rejected, so its text can't be changed. A rejected entry stays rejected; file a new entry if the
|
|
871
|
+
: "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.";
|
|
699
872
|
}
|
|
700
873
|
}
|
|
701
874
|
|
|
@@ -721,7 +894,7 @@ export function formatTransitionRefusal(
|
|
|
721
894
|
? `Entry ${entryFile} is already applied, so it can't be approved. If the anchor goes stale, the entry returns to approved.`
|
|
722
895
|
: `Entry ${entryFile} is already applied, so it can't be rejected. If the anchor goes stale, it returns to approved and can be rejected then.`;
|
|
723
896
|
case "rejected":
|
|
724
|
-
return `Entry ${entryFile} was rejected, and a rejected entry stays rejected. File a new entry if the
|
|
897
|
+
return `Entry ${entryFile} was rejected, and a rejected entry stays rejected. File a new entry if the passage still needs changing.`;
|
|
725
898
|
}
|
|
726
899
|
}
|
|
727
900
|
}
|
|
@@ -770,9 +943,8 @@ function EditorRegion({ vm, state }: { vm: EditorViewModel; state: EditorState |
|
|
|
770
943
|
>
|
|
771
944
|
<input type="hidden" name="view" value="editor" />
|
|
772
945
|
<div class="card-header">
|
|
773
|
-
<div class="card-title">
|
|
946
|
+
<div class="card-title editor-title">{entryTitle(vm.entry)}</div>
|
|
774
947
|
<span class={`tag ${STATUS_TAG[vm.entry.status]}`}>
|
|
775
|
-
<span class="tag-dot"></span>
|
|
776
948
|
{STATUS_LABEL[vm.entry.status]}
|
|
777
949
|
</span>
|
|
778
950
|
</div>
|
|
@@ -859,13 +1031,12 @@ function RevisionHistory({ revisions }: { revisions: readonly RevisionRow[] }) {
|
|
|
859
1031
|
);
|
|
860
1032
|
}
|
|
861
1033
|
|
|
862
|
-
/** The constraint panel: what the entry's save enforces, plus the display-only tone
|
|
1034
|
+
/** The constraint panel: what the entry's save enforces (length, placeholders), plus the display-only tone. The notes moved to their own card, so the enforced fields stay the card's lead. */
|
|
863
1035
|
function ConstraintsCard({ constraints, text }: { constraints: Constraints; text: string }) {
|
|
864
1036
|
const maxLen = constraints.maxLen;
|
|
865
1037
|
const placeholders = constraints.placeholders;
|
|
866
1038
|
const tone = constraints.tone;
|
|
867
|
-
const
|
|
868
|
-
const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined || notes !== undefined;
|
|
1039
|
+
const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined;
|
|
869
1040
|
if (!hasAny) return null;
|
|
870
1041
|
return (
|
|
871
1042
|
<div class="card">
|
|
@@ -907,18 +1078,146 @@ function ConstraintsCard({ constraints, text }: { constraints: Constraints; text
|
|
|
907
1078
|
<div class="constraint-value">{tone}</div>
|
|
908
1079
|
</div>
|
|
909
1080
|
) : null}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1081
|
+
</div>
|
|
1082
|
+
</div>
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* The notes card: the agent's notes (from `constraints.notes`, read-only) above the human's own
|
|
1088
|
+
* notes as an editable textarea with its own save. The textarea carries `hx-preserve`, so any swap
|
|
1089
|
+
* (a text save, an image add) keeps the human's in-progress note; the save targets the whole
|
|
1090
|
+
* editor view like the save form, so the saved value re-renders in place. The card always renders:
|
|
1091
|
+
* the human's notes need a home even before the agent sent any.
|
|
1092
|
+
*/
|
|
1093
|
+
function NotesCard({ vm }: { vm: EditorViewModel }) {
|
|
1094
|
+
return (
|
|
1095
|
+
<div class="card">
|
|
1096
|
+
<div class="card-header">
|
|
1097
|
+
<div class="card-title">Notes</div>
|
|
1098
|
+
</div>
|
|
1099
|
+
<div class="card-content">
|
|
1100
|
+
<div class="constraint-row">
|
|
1101
|
+
<div class="constraint-label">Agent's notes</div>
|
|
1102
|
+
<div class="constraint-value">{vm.constraints.notes ?? "None."}</div>
|
|
1103
|
+
</div>
|
|
1104
|
+
<form
|
|
1105
|
+
id="notes-form"
|
|
1106
|
+
method="post"
|
|
1107
|
+
action={`/entries/${vm.entry.id}/notes`}
|
|
1108
|
+
hx-post={`/entries/${vm.entry.id}/notes`}
|
|
1109
|
+
hx-target="#editor-view"
|
|
1110
|
+
hx-swap="outerHTML"
|
|
1111
|
+
>
|
|
1112
|
+
<div class="field notes-field">
|
|
1113
|
+
<label class="field-label" for="editor-notes">Your notes</label>
|
|
1114
|
+
<textarea
|
|
1115
|
+
id="editor-notes"
|
|
1116
|
+
class="input"
|
|
1117
|
+
name="notes"
|
|
1118
|
+
rows={4}
|
|
1119
|
+
aria-label="Your notes"
|
|
1120
|
+
hx-preserve="true"
|
|
1121
|
+
>
|
|
1122
|
+
{vm.entry.human_notes ?? ""}
|
|
1123
|
+
</textarea>
|
|
914
1124
|
</div>
|
|
915
|
-
|
|
1125
|
+
<div class="notes-actions">
|
|
1126
|
+
<button class="btn btn-secondary btn-sm" type="submit">Save notes</button>
|
|
1127
|
+
</div>
|
|
1128
|
+
</form>
|
|
916
1129
|
</div>
|
|
917
1130
|
</div>
|
|
918
1131
|
);
|
|
919
1132
|
}
|
|
920
1133
|
|
|
921
|
-
/**
|
|
1134
|
+
/** 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. */
|
|
1135
|
+
function isRenderableImage(item: string): boolean {
|
|
1136
|
+
return item.startsWith("data:image/") || /^https?:\/\//.test(item);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* The images attached to an entry, one card in the rail: each valid item as a real `img` (sized by
|
|
1141
|
+
* css), an invalid one as a "not shown" note, a remove form per image, and the one-file upload
|
|
1142
|
+
* form. A card of its own, not a strip in the context card: upload and remove need a stable home,
|
|
1143
|
+
* and the context card stays lean for what the human authors against. Both forms target the whole
|
|
1144
|
+
* editor view like the save form, so a change re-renders the card in place.
|
|
1145
|
+
*/
|
|
1146
|
+
function ImagesCard({ entry }: { entry: EntryRow }) {
|
|
1147
|
+
const images = parseEntryImages(entry.images);
|
|
1148
|
+
return (
|
|
1149
|
+
<div class="card">
|
|
1150
|
+
<div class="card-header">
|
|
1151
|
+
<div class="card-title">Images</div>
|
|
1152
|
+
</div>
|
|
1153
|
+
<div class="card-content">
|
|
1154
|
+
{images.length === 0 ? (
|
|
1155
|
+
<p class="field-hint">No images yet.</p>
|
|
1156
|
+
) : (
|
|
1157
|
+
<ul class="image-list">
|
|
1158
|
+
{images.map((item, index) =>
|
|
1159
|
+
isRenderableImage(item) ? (
|
|
1160
|
+
<li class="image-item" key={index}>
|
|
1161
|
+
<img class="entry-image" src={item} alt={`Image ${index + 1}`} loading="lazy" />
|
|
1162
|
+
<form
|
|
1163
|
+
class="image-remove"
|
|
1164
|
+
method="post"
|
|
1165
|
+
action={`/entries/${entry.id}/images/${index}/remove`}
|
|
1166
|
+
hx-post={`/entries/${entry.id}/images/${index}/remove`}
|
|
1167
|
+
hx-target="#editor-view"
|
|
1168
|
+
hx-swap="outerHTML"
|
|
1169
|
+
>
|
|
1170
|
+
<button class="btn btn-ghost btn-sm" type="submit">Remove</button>
|
|
1171
|
+
</form>
|
|
1172
|
+
</li>
|
|
1173
|
+
) : (
|
|
1174
|
+
<li class="image-item" key={index}>
|
|
1175
|
+
<p class="image-skip">Image {index + 1} not shown: unsupported source.</p>
|
|
1176
|
+
</li>
|
|
1177
|
+
),
|
|
1178
|
+
)}
|
|
1179
|
+
</ul>
|
|
1180
|
+
)}
|
|
1181
|
+
<form
|
|
1182
|
+
class="image-upload"
|
|
1183
|
+
method="post"
|
|
1184
|
+
action={`/entries/${entry.id}/images`}
|
|
1185
|
+
enctype="multipart/form-data"
|
|
1186
|
+
hx-post={`/entries/${entry.id}/images`}
|
|
1187
|
+
hx-encoding="multipart/form-data"
|
|
1188
|
+
hx-target="#editor-view"
|
|
1189
|
+
hx-swap="outerHTML"
|
|
1190
|
+
>
|
|
1191
|
+
<input class="input" type="file" name="file" accept="image/*" aria-label="Image file" />
|
|
1192
|
+
<button class="btn btn-secondary btn-sm" type="submit" hx-disabled-elt="this">Add image</button>
|
|
1193
|
+
</form>
|
|
1194
|
+
</div>
|
|
1195
|
+
</div>
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* The repo row's value: a recognized remote renders as its brand icon beside the bare owner/repo
|
|
1201
|
+
* path, both linking out to the host; an unrecognized one renders as the raw string as filed.
|
|
1202
|
+
*/
|
|
1203
|
+
function RepoValue({ repo }: { repo: string }) {
|
|
1204
|
+
const link = parseRepoLink(repo);
|
|
1205
|
+
if (link === null) return <>{repo}</>;
|
|
1206
|
+
return (
|
|
1207
|
+
<a class="repo-link" href={link.href} target="_blank" rel="noopener noreferrer" title={repo}>
|
|
1208
|
+
<svg class="repo-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
1209
|
+
<path d={REPO_ICON[link.provider]} />
|
|
1210
|
+
</svg>
|
|
1211
|
+
<span>{link.path}</span>
|
|
1212
|
+
</a>
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* The entry's context JSON plus its anchor metadata, as an info-table readout. The anchor and the
|
|
1218
|
+
* agent-supplied context rows lead — they are what the human authors against — then repo and file;
|
|
1219
|
+
* the provenance rows (hash, filer, stale note) are secondary and sit collapsed behind a summary.
|
|
1220
|
+
*/
|
|
922
1221
|
function ContextCard({ entry }: { entry: EntryRow }) {
|
|
923
1222
|
const context = parseContext(entry.context);
|
|
924
1223
|
return (
|
|
@@ -928,6 +1227,10 @@ function ContextCard({ entry }: { entry: EntryRow }) {
|
|
|
928
1227
|
</div>
|
|
929
1228
|
<div class="card-content">
|
|
930
1229
|
<table class="info-table">
|
|
1230
|
+
<tr>
|
|
1231
|
+
<td>Anchor</td>
|
|
1232
|
+
<td>{entry.anchor_text}</td>
|
|
1233
|
+
</tr>
|
|
931
1234
|
{Object.entries(context).map(([key, value]) => (
|
|
932
1235
|
<tr key={key}>
|
|
933
1236
|
<td>{key}</td>
|
|
@@ -936,33 +1239,34 @@ function ContextCard({ entry }: { entry: EntryRow }) {
|
|
|
936
1239
|
))}
|
|
937
1240
|
<tr>
|
|
938
1241
|
<td>Repo</td>
|
|
939
|
-
<td
|
|
1242
|
+
<td><RepoValue repo={entry.repo} /></td>
|
|
940
1243
|
</tr>
|
|
941
1244
|
<tr>
|
|
942
1245
|
<td>File</td>
|
|
943
1246
|
<td>{entry.file}</td>
|
|
944
1247
|
</tr>
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
<tr>
|
|
950
|
-
<td>File hash</td>
|
|
951
|
-
<td>{entry.file_hash}</td>
|
|
952
|
-
</tr>
|
|
953
|
-
{entry.filed_by !== null ? (
|
|
954
|
-
<tr>
|
|
955
|
-
<td>Filed by</td>
|
|
956
|
-
<td>{entry.filed_by}</td>
|
|
957
|
-
</tr>
|
|
958
|
-
) : null}
|
|
959
|
-
{entry.stale_note !== null ? (
|
|
1248
|
+
</table>
|
|
1249
|
+
<details class="context-secondary">
|
|
1250
|
+
<summary>More</summary>
|
|
1251
|
+
<table class="info-table">
|
|
960
1252
|
<tr>
|
|
961
|
-
<td>
|
|
962
|
-
<td>{entry.
|
|
1253
|
+
<td>File hash</td>
|
|
1254
|
+
<td>{entry.file_hash}</td>
|
|
963
1255
|
</tr>
|
|
964
|
-
|
|
965
|
-
|
|
1256
|
+
{entry.filed_by !== null ? (
|
|
1257
|
+
<tr>
|
|
1258
|
+
<td>Filed by</td>
|
|
1259
|
+
<td>{entry.filed_by}</td>
|
|
1260
|
+
</tr>
|
|
1261
|
+
) : null}
|
|
1262
|
+
{entry.stale_note !== null ? (
|
|
1263
|
+
<tr>
|
|
1264
|
+
<td>Stale note</td>
|
|
1265
|
+
<td>{entry.stale_note}</td>
|
|
1266
|
+
</tr>
|
|
1267
|
+
) : null}
|
|
1268
|
+
</table>
|
|
1269
|
+
</details>
|
|
966
1270
|
</div>
|
|
967
1271
|
</div>
|
|
968
1272
|
);
|
|
@@ -970,14 +1274,18 @@ function ContextCard({ entry }: { entry: EntryRow }) {
|
|
|
970
1274
|
|
|
971
1275
|
/**
|
|
972
1276
|
* The before-and-after of the entry's text, above the editor: what the anchored region reads now
|
|
973
|
-
* (the anchor
|
|
974
|
-
* escape through JSX; the text is the stored bytes, shown as-is.
|
|
1277
|
+
* (the anchor visually distinct) and what the entry's text will replace it with. Both sides
|
|
1278
|
+
* escape through JSX; the text is the stored bytes, shown as-is. The word-level marks come from
|
|
1279
|
+
* the server-side walk: words only the anchor has render red in the before card, words only the proposal
|
|
1280
|
+
* has render green in the after card, everything shared stays plain, so an identical pair renders
|
|
1281
|
+
* no marks at all.
|
|
975
1282
|
*/
|
|
976
1283
|
function DiffView({ entry }: { entry: EntryRow }) {
|
|
977
1284
|
const after = entry.human_text ?? entry.agent_draft;
|
|
978
1285
|
// No text at all means nothing to compare: a filed entry whose agent never drafted, or an
|
|
979
1286
|
// approved entry whose text was never set.
|
|
980
1287
|
if (after === null) return null;
|
|
1288
|
+
const diff = diffWords(entry.anchor_text, after);
|
|
981
1289
|
return (
|
|
982
1290
|
<div class="diff-grid">
|
|
983
1291
|
<div class="card">
|
|
@@ -986,7 +1294,15 @@ function DiffView({ entry }: { entry: EntryRow }) {
|
|
|
986
1294
|
</div>
|
|
987
1295
|
<pre class="diff-code">
|
|
988
1296
|
<span class="diff-line">{entry.anchor_before}</span>
|
|
989
|
-
<span class="diff-line diff-anchor">
|
|
1297
|
+
<span class="diff-line diff-anchor">
|
|
1298
|
+
{diff.before.map((token, index) =>
|
|
1299
|
+
token.kind === "removed" ? (
|
|
1300
|
+
<span class="diff-removed" key={index}>{token.text}</span>
|
|
1301
|
+
) : (
|
|
1302
|
+
token.text
|
|
1303
|
+
),
|
|
1304
|
+
)}
|
|
1305
|
+
</span>
|
|
990
1306
|
<span class="diff-line">{entry.anchor_after}</span>
|
|
991
1307
|
</pre>
|
|
992
1308
|
</div>
|
|
@@ -994,7 +1310,15 @@ function DiffView({ entry }: { entry: EntryRow }) {
|
|
|
994
1310
|
<div class="card-header">
|
|
995
1311
|
<div class="card-title">After</div>
|
|
996
1312
|
</div>
|
|
997
|
-
<pre class="diff-code">
|
|
1313
|
+
<pre class="diff-code">
|
|
1314
|
+
{diff.after.map((token, index) =>
|
|
1315
|
+
token.kind === "added" ? (
|
|
1316
|
+
<span class="diff-added" key={index}>{token.text}</span>
|
|
1317
|
+
) : (
|
|
1318
|
+
token.text
|
|
1319
|
+
),
|
|
1320
|
+
)}
|
|
1321
|
+
</pre>
|
|
998
1322
|
</div>
|
|
999
1323
|
</div>
|
|
1000
1324
|
);
|
|
@@ -1017,12 +1341,48 @@ function EditorView({ vm, state }: { vm: EditorViewModel; state: EditorState | u
|
|
|
1017
1341
|
<aside class="editor-rail">
|
|
1018
1342
|
<ContextCard entry={vm.entry} />
|
|
1019
1343
|
<ConstraintsCard constraints={vm.constraints} text={text} />
|
|
1344
|
+
<NotesCard vm={vm} />
|
|
1345
|
+
<ImagesCard entry={vm.entry} />
|
|
1020
1346
|
</aside>
|
|
1021
1347
|
</div>
|
|
1022
1348
|
</div>
|
|
1023
1349
|
);
|
|
1024
1350
|
}
|
|
1025
1351
|
|
|
1352
|
+
/** The editor's full-height prev/next rails: one fixed strip per side, a link when a neighbour exists. */
|
|
1353
|
+
function EditorNav({ prevId, nextId }: { prevId: string | null; nextId: string | null }) {
|
|
1354
|
+
return (
|
|
1355
|
+
<nav class="editor-nav" aria-label="Entry navigation">
|
|
1356
|
+
<EditorNavButton side="prev" targetId={prevId} />
|
|
1357
|
+
<EditorNavButton side="next" targetId={nextId} />
|
|
1358
|
+
</nav>
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** One rail: a chevron on a full-height strip, or an inert placeholder at the boundary. */
|
|
1363
|
+
function EditorNavButton({ side, targetId }: { side: "prev" | "next"; targetId: string | null }) {
|
|
1364
|
+
const label = side === "prev" ? "Previous entry" : "Next entry";
|
|
1365
|
+
const chevron = side === "prev" ? "M10 3L5 8l5 5" : "M6 3l5 5-5 5";
|
|
1366
|
+
const cls = `editor-nav-btn editor-nav-${side}`;
|
|
1367
|
+
const icon = (
|
|
1368
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
1369
|
+
<path d={chevron} />
|
|
1370
|
+
</svg>
|
|
1371
|
+
);
|
|
1372
|
+
if (targetId === null) {
|
|
1373
|
+
return (
|
|
1374
|
+
<span class={`${cls} is-disabled`} aria-hidden="true">
|
|
1375
|
+
{icon}
|
|
1376
|
+
</span>
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
return (
|
|
1380
|
+
<a class={cls} href={`/entries/${targetId}`} aria-label={label} title={label}>
|
|
1381
|
+
{icon}
|
|
1382
|
+
</a>
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1026
1386
|
/** The full editor page; the mount wraps it in the doctype. `signedIn` is whether the navbar offers sign-out. */
|
|
1027
1387
|
export function editorPage(vm: EditorViewModel, state: EditorState | undefined = undefined, signedIn = false): JSX.Element {
|
|
1028
1388
|
return (
|
|
@@ -1049,6 +1409,7 @@ export function editorPage(vm: EditorViewModel, state: EditorState | undefined =
|
|
|
1049
1409
|
<p class="page-lede">Saving the text approves the entry and releases it to agents.</p>
|
|
1050
1410
|
</header>
|
|
1051
1411
|
<EditorView vm={vm} state={state} />
|
|
1412
|
+
<EditorNav prevId={vm.prevId} nextId={vm.nextId} />
|
|
1052
1413
|
{/* The one live region: announcements on constraint-state flips only, so a screen reader
|
|
1053
1414
|
is not flooded with a per-keystroke value. Lives outside the swap region. */}
|
|
1054
1415
|
<span id="editor-live" class="visually-hidden" aria-live="polite"></span>
|