reviewzy 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/dashboard/mount.ts +20 -3
- package/src/dashboard/static/components.css +7579 -600
- package/src/dashboard/static/cursor-rules.js +79 -0
- package/src/dashboard/static/cursor.css +134 -0
- package/src/dashboard/static/cursor.js +1749 -0
- package/src/dashboard/static/dashboard.css +281 -258
- package/src/dashboard/static/dashboard.js +331 -209
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/icons.svg +184 -0
- package/src/dashboard/static/tokens.css +607 -110
- package/src/dashboard/static/ui.js +2487 -0
- package/src/dashboard/views.tsx +1204 -950
- package/src/dashboard/word-diff.ts +18 -25
- package/src/db/migrations.ts +5 -6
- package/src/db/queries.ts +38 -1
- package/src/mcp/await-approved.ts +17 -10
- package/src/mcp/fetch-approved.ts +11 -6
- package/src/mcp/file-entries.ts +45 -22
- package/src/mcp/list-entries.ts +51 -55
- package/src/mcp/mark-applied.ts +5 -5
- package/src/mcp/server.ts +8 -3
package/src/dashboard/views.tsx
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/** @jsxImportSource hono/jsx */
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { raw } from "hono/html";
|
|
4
|
+
import type { Child } from "hono/jsx";
|
|
2
5
|
import type { JSX } from "hono/jsx/jsx-runtime";
|
|
3
6
|
import { listRevisions, parseConstraints, parseEntryImages } from "../db/human-save.ts";
|
|
4
7
|
import type { Constraints, HumanSaveRefusal, RevisionRow, TransitionRefusal } from "../db/human-save.ts";
|
|
5
8
|
import {
|
|
6
9
|
adjacentEntryIds,
|
|
7
10
|
countEntries,
|
|
11
|
+
countEntriesByStatus,
|
|
8
12
|
listEntries,
|
|
9
13
|
listProjects,
|
|
10
14
|
projectIdBySlug,
|
|
@@ -15,7 +19,8 @@ import type { StyleGuideForm, StyleGuideFormRefusal, StyleGuideInput } from "../
|
|
|
15
19
|
import type { Store } from "../db/store.ts";
|
|
16
20
|
import { parseRepoLink, REPO_ICON } from "./repo-link.ts";
|
|
17
21
|
import { diffWords } from "./word-diff.ts";
|
|
18
|
-
import {
|
|
22
|
+
import type { WordDiffToken } from "./word-diff.ts";
|
|
23
|
+
import { NAME } from "../version.ts";
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* The three dashboard filters, straight off the query string. `status` and `project` are the raw
|
|
@@ -33,7 +38,7 @@ const PAGE_SIZE = 200;
|
|
|
33
38
|
|
|
34
39
|
const STATUSES: readonly EntryStatus[] = ["draft", "approved", "applied", "rejected"];
|
|
35
40
|
|
|
36
|
-
/** The default
|
|
41
|
+
/** The default Open view: the two statuses that still need a human. */
|
|
37
42
|
const ACTIVE_STATUSES: readonly EntryStatus[] = ["draft", "approved"];
|
|
38
43
|
|
|
39
44
|
function isEntryStatus(value: string): value is EntryStatus {
|
|
@@ -54,11 +59,20 @@ const STATUS_LABEL: Record<EntryStatus, string> = {
|
|
|
54
59
|
rejected: "Rejected",
|
|
55
60
|
};
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
|
|
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[] };
|
|
59
71
|
|
|
60
72
|
export type ListViewModel = {
|
|
61
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>;
|
|
62
76
|
readonly shown: number;
|
|
63
77
|
readonly groups: readonly ProjectGroup[];
|
|
64
78
|
readonly projects: readonly { id: string; slug: string }[];
|
|
@@ -67,7 +81,7 @@ export type ListViewModel = {
|
|
|
67
81
|
readonly status: string;
|
|
68
82
|
readonly project: string;
|
|
69
83
|
readonly filtersActive: boolean;
|
|
70
|
-
/** 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. */
|
|
71
85
|
readonly hiddenByActiveView: boolean;
|
|
72
86
|
};
|
|
73
87
|
|
|
@@ -94,63 +108,19 @@ function allEntries(
|
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
/**
|
|
97
|
-
* Groups the filtered rows by project
|
|
98
|
-
* list query's ascending id order
|
|
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.
|
|
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.
|
|
102
113
|
*/
|
|
103
114
|
function groupRows(rows: readonly EntryRow[], slugOf: Map<string, string>): ProjectGroup[] {
|
|
104
115
|
const byProject = new Map<string, EntryRow[]>();
|
|
105
116
|
for (const row of rows) {
|
|
106
117
|
byProject.set(row.project_id, [...(byProject.get(row.project_id) ?? []), row]);
|
|
107
118
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
byBatch.set(row.batch_id, [...(byBatch.get(row.batch_id) ?? []), row]);
|
|
114
|
-
}
|
|
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] };
|
|
119
|
-
});
|
|
120
|
-
}
|
|
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);
|
|
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));
|
|
154
124
|
}
|
|
155
125
|
|
|
156
126
|
function loadList(store: Store, params: ListParams): ListViewModel {
|
|
@@ -159,8 +129,8 @@ function loadList(store: Store, params: ListParams): ListViewModel {
|
|
|
159
129
|
const statusRaw = params.status;
|
|
160
130
|
const statusActive = statusRaw !== undefined && statusRaw !== "";
|
|
161
131
|
// `all` is the explicit "every status" filter value; the default (no status param, or an empty
|
|
162
|
-
// one) is the
|
|
163
|
-
// 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.
|
|
164
134
|
const allStatuses = statusActive && statusRaw === "all";
|
|
165
135
|
const status = statusActive && !allStatuses && isEntryStatus(statusRaw) ? statusRaw : undefined;
|
|
166
136
|
const projectRaw = params.project;
|
|
@@ -176,6 +146,7 @@ function loadList(store: Store, params: ListParams): ListViewModel {
|
|
|
176
146
|
const slugOf = new Map(projects.map((project) => [project.id, project.slug]));
|
|
177
147
|
return {
|
|
178
148
|
total: countEntries(store),
|
|
149
|
+
counts: countEntriesByStatus(store),
|
|
179
150
|
shown: rows.length,
|
|
180
151
|
groups: groupRows(rows, slugOf),
|
|
181
152
|
projects,
|
|
@@ -205,45 +176,282 @@ function entryTitle(entry: EntryRow): string {
|
|
|
205
176
|
return fileBasename(entry.file);
|
|
206
177
|
}
|
|
207
178
|
|
|
208
|
-
/**
|
|
209
|
-
function
|
|
210
|
-
|
|
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 !== "";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The section the current page belongs to, marking the matching nav link. */
|
|
186
|
+
type NavSection = "entries" | "style-guide";
|
|
187
|
+
|
|
188
|
+
/** The icon sprite, pasted into every full page's body, read off the vendored file so the two can never drift. */
|
|
189
|
+
const ICON_SPRITE = readFileSync(new URL("./static/icons.svg", import.meta.url), "utf8");
|
|
190
|
+
|
|
191
|
+
/** The theme toggle, once per shell: ui.js's initTheme wires it and owns the pressed state. */
|
|
192
|
+
function ThemeToggle() {
|
|
193
|
+
return (
|
|
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
|
+
);
|
|
211
199
|
}
|
|
212
200
|
|
|
213
|
-
/**
|
|
214
|
-
|
|
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
|
+
}
|
|
215
212
|
|
|
216
|
-
|
|
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
|
+
}) {
|
|
217
230
|
return (
|
|
218
|
-
|
|
219
|
-
<div class="
|
|
231
|
+
<>
|
|
232
|
+
<div class="mobile-topbar">
|
|
220
233
|
<div class="logo-name">{NAME}</div>
|
|
221
|
-
<
|
|
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>
|
|
244
|
+
</div>
|
|
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>
|
|
222
293
|
</div>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
))}
|
|
227
360
|
</div>
|
|
228
|
-
|
|
229
|
-
<form method="post" action="/logout" class="navbar-actions">
|
|
230
|
-
<button class="btn btn-ghost btn-sm" type="submit">Sign out</button>
|
|
231
|
-
</form>
|
|
232
|
-
) : null}
|
|
233
|
-
</nav>
|
|
361
|
+
</div>
|
|
234
362
|
);
|
|
235
363
|
}
|
|
236
364
|
|
|
237
|
-
|
|
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>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function PageHeader({ vm }: { vm: ListViewModel }) {
|
|
238
382
|
return (
|
|
239
383
|
<header class="page-header">
|
|
240
|
-
<
|
|
241
|
-
|
|
384
|
+
<div class="page-header-body">
|
|
385
|
+
<h1 class="page-title">Entries</h1>
|
|
386
|
+
<PageMeta draftCount={vm.counts.draft} total={vm.total} projects={vm.projects.length} />
|
|
387
|
+
</div>
|
|
242
388
|
</header>
|
|
243
389
|
);
|
|
244
390
|
}
|
|
245
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
|
+
*/
|
|
246
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
|
+
];
|
|
247
455
|
return (
|
|
248
456
|
<form
|
|
249
457
|
id="filters"
|
|
@@ -253,89 +461,110 @@ function FilterForm({ vm }: { vm: ListViewModel }) {
|
|
|
253
461
|
hx-target="#entries-list"
|
|
254
462
|
hx-swap="innerHTML"
|
|
255
463
|
hx-indicator="#entries-loading"
|
|
256
|
-
class="filters"
|
|
257
464
|
>
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
<label class="field-label" for="status">Status</label>
|
|
271
|
-
<select class="input" id="status" name="status">
|
|
272
|
-
<option value="" selected={vm.status === ""}>Active</option>
|
|
273
|
-
<option value="all" selected={vm.status === "all"}>All statuses</option>
|
|
274
|
-
<option value="draft" selected={vm.status === "draft"}>Draft</option>
|
|
275
|
-
<option value="approved" selected={vm.status === "approved"}>Approved</option>
|
|
276
|
-
<option value="applied" selected={vm.status === "applied"}>Applied</option>
|
|
277
|
-
<option value="rejected" selected={vm.status === "rejected"}>Rejected</option>
|
|
278
|
-
</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} />
|
|
279
477
|
</div>
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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} />
|
|
290
513
|
</div>
|
|
291
|
-
<noscript>
|
|
292
|
-
<button class="btn btn-primary" type="submit">Apply filters</button>
|
|
293
|
-
</noscript>
|
|
294
514
|
</form>
|
|
295
515
|
);
|
|
296
516
|
}
|
|
297
517
|
|
|
298
518
|
/**
|
|
299
|
-
* What an action round settles into, rendered
|
|
300
|
-
* could not move, why, and the fix) or the batch report (how many moved, each refusal
|
|
301
|
-
* 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.
|
|
302
522
|
*/
|
|
303
523
|
export type ListNotice =
|
|
304
524
|
| { readonly kind: "refusal"; readonly title: string; readonly message: string }
|
|
305
525
|
| { readonly kind: "batch"; readonly approved: number; readonly total: number; readonly refused: readonly string[] };
|
|
306
526
|
|
|
307
|
-
/** The
|
|
308
|
-
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 }) {
|
|
309
538
|
if (notice.kind === "refusal") {
|
|
310
|
-
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
|
+
);
|
|
311
549
|
}
|
|
312
550
|
if (notice.refused.length === 0) {
|
|
313
551
|
return (
|
|
314
|
-
<div class="
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
<path d="M5 8l2 2 4-4" />
|
|
319
|
-
</svg>
|
|
320
|
-
</div>
|
|
321
|
-
<div class="callout-content">
|
|
322
|
-
<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">
|
|
323
556
|
Approved {notice.approved} {notice.approved === 1 ? "entry" : "entries"}
|
|
324
557
|
</div>
|
|
325
558
|
</div>
|
|
559
|
+
<BannerDismiss />
|
|
326
560
|
</div>
|
|
327
561
|
);
|
|
328
562
|
}
|
|
329
563
|
return (
|
|
330
|
-
<div class="
|
|
331
|
-
<
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
<path d="M10 6L6 10M6 6l4 4" />
|
|
335
|
-
</svg>
|
|
336
|
-
</div>
|
|
337
|
-
<div class="callout-content">
|
|
338
|
-
<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">
|
|
339
568
|
Approved {notice.approved} of {notice.total} entries
|
|
340
569
|
</div>
|
|
341
570
|
<ul class="notice-list">
|
|
@@ -344,88 +573,66 @@ function NoticeCallout({ notice }: { notice: ListNotice }) {
|
|
|
344
573
|
))}
|
|
345
574
|
</ul>
|
|
346
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}
|
|
347
586
|
</div>
|
|
348
587
|
);
|
|
349
588
|
}
|
|
350
589
|
|
|
351
|
-
/** The settled list region:
|
|
352
|
-
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 }) {
|
|
353
592
|
if (vm.shown === 0) {
|
|
354
593
|
// Zero entries at all is "no entries yet" even under a filter: nothing exists to filter, and
|
|
355
594
|
// the empty state explains how entries get here. A store with entries but no matches is either
|
|
356
|
-
// a filter miss or the default
|
|
357
|
-
return (
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
/>
|
|
370
|
-
) : (
|
|
371
|
-
<EmptyActive />
|
|
372
|
-
)}
|
|
373
|
-
</>
|
|
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 />
|
|
374
608
|
);
|
|
375
609
|
}
|
|
376
|
-
return
|
|
377
|
-
<>
|
|
378
|
-
{notice !== undefined ? <NoticeCallout notice={notice} /> : null}
|
|
379
|
-
{vm.filtersActive && vm.shown < vm.total ? <PartialHeader shown={vm.shown} total={vm.total} /> : null}
|
|
380
|
-
<BatchForm vm={vm} />
|
|
381
|
-
</>
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function PartialHeader({ shown, total }: { shown: number; total: number }) {
|
|
386
|
-
return (
|
|
387
|
-
<div class="partial-header">
|
|
388
|
-
<span class="partial-count">
|
|
389
|
-
Showing {shown} of {total} entries
|
|
390
|
-
</span>
|
|
391
|
-
<a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
|
|
392
|
-
</div>
|
|
393
|
-
);
|
|
610
|
+
return <BatchForm vm={vm} />;
|
|
394
611
|
}
|
|
395
612
|
|
|
396
613
|
function EmptyAll() {
|
|
397
614
|
return (
|
|
398
|
-
<div class="
|
|
399
|
-
<
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
<path d="M1.5 9.5h3.5l1 2h4l1-2h3.5" />
|
|
403
|
-
</svg>
|
|
404
|
-
</div>
|
|
405
|
-
<h3>No entries yet</h3>
|
|
406
|
-
<p>Agents file text through the reviewzy mcp endpoint. Filed entries appear here.</p>
|
|
615
|
+
<div class="empty-state">
|
|
616
|
+
<svg aria-hidden="true"><use href="#i-file" /></svg>
|
|
617
|
+
<div class="empty-title">No entries yet</div>
|
|
618
|
+
<div>Agents file text through the reviewzy mcp endpoint. Filed entries appear here.</div>
|
|
407
619
|
</div>
|
|
408
620
|
);
|
|
409
621
|
}
|
|
410
622
|
|
|
411
623
|
/**
|
|
412
|
-
* 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
|
|
413
625
|
* 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
|
|
626
|
+
* view under the same filters — clearing the filters alone would land back on the Open view
|
|
415
627
|
* and still show nothing.
|
|
416
628
|
*/
|
|
417
629
|
function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
|
|
418
630
|
return (
|
|
419
|
-
<div class="
|
|
420
|
-
<
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
</svg>
|
|
425
|
-
</div>
|
|
426
|
-
<h3>No entries match</h3>
|
|
427
|
-
<p>Try different search terms, or clear the filters.</p>
|
|
428
|
-
{revealAllHref !== undefined ? <p>Applied and rejected entries are hidden from this view.</p> : null}
|
|
631
|
+
<div class="empty-state">
|
|
632
|
+
<svg aria-hidden="true"><use href="#i-search" /></svg>
|
|
633
|
+
<div class="empty-title">No entries match</div>
|
|
634
|
+
<div>Try different search terms, or clear the filters.</div>
|
|
635
|
+
{revealAllHref !== undefined ? <div>Applied and rejected entries are hidden from this view.</div> : null}
|
|
429
636
|
<div class="empty-actions">
|
|
430
637
|
<a href="/" class="btn btn-secondary btn-sm">Clear filters</a>
|
|
431
638
|
{revealAllHref !== undefined ? (
|
|
@@ -436,28 +643,24 @@ function EmptyMatch({ revealAllHref }: { revealAllHref: string | undefined }) {
|
|
|
436
643
|
);
|
|
437
644
|
}
|
|
438
645
|
|
|
439
|
-
/** The default
|
|
646
|
+
/** The default Open view over a store whose entries are all applied or rejected: nothing to do, no filters to clear. */
|
|
440
647
|
function EmptyActive() {
|
|
441
648
|
return (
|
|
442
|
-
<div class="
|
|
443
|
-
<
|
|
444
|
-
|
|
445
|
-
|
|
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>
|
|
649
|
+
<div class="empty-state">
|
|
650
|
+
<svg aria-hidden="true"><use href="#i-success" /></svg>
|
|
651
|
+
<div class="empty-title">Nothing to review</div>
|
|
652
|
+
<div>Every filed entry is applied or rejected. New drafts appear here as agents file them.</div>
|
|
451
653
|
</div>
|
|
452
654
|
);
|
|
453
655
|
}
|
|
454
656
|
|
|
455
657
|
/**
|
|
456
|
-
* The batch approve form wrapping
|
|
658
|
+
* The batch approve form wrapping every project table: the checkboxes post as `id`, the hidden
|
|
457
659
|
* inputs carry the active filters so an action round re-renders the region under the same filter
|
|
458
660
|
* (htmx includes the closest form's inputs for the row buttons too), and the native action keeps
|
|
459
661
|
* no-JS batch approve working. One form per page, never nested: the filters form lives outside the
|
|
460
|
-
* 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.
|
|
461
664
|
*/
|
|
462
665
|
function BatchForm({ vm }: { vm: ListViewModel }) {
|
|
463
666
|
return (
|
|
@@ -473,234 +676,210 @@ function BatchForm({ vm }: { vm: ListViewModel }) {
|
|
|
473
676
|
<input type="hidden" name="q" value={vm.q} />
|
|
474
677
|
<input type="hidden" name="status" value={vm.status} />
|
|
475
678
|
<input type="hidden" name="project" value={vm.project} />
|
|
476
|
-
<div class="batch-bar">
|
|
477
|
-
<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">
|
|
478
681
|
Approve selected
|
|
479
682
|
</button>
|
|
480
683
|
</div>
|
|
481
684
|
{vm.groups.map((group) => (
|
|
482
|
-
<
|
|
685
|
+
<ProjectTable key={group.slug} group={group} />
|
|
483
686
|
))}
|
|
484
687
|
</form>
|
|
485
688
|
);
|
|
486
689
|
}
|
|
487
690
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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>
|
|
513
|
-
</header>
|
|
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>
|
|
519
|
-
</section>
|
|
520
|
-
);
|
|
521
|
-
}
|
|
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);
|
|
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";
|
|
530
701
|
return (
|
|
531
|
-
<
|
|
532
|
-
<div class="
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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>
|
|
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>
|
|
551
708
|
</div>
|
|
552
|
-
<div class="table-wrap
|
|
709
|
+
<div class="table-wrap table-dense">
|
|
553
710
|
<table>
|
|
554
711
|
<colgroup>
|
|
555
712
|
<col class="col-select" />
|
|
556
713
|
<col />
|
|
557
714
|
<col class="col-status" />
|
|
558
|
-
<col class="col-
|
|
715
|
+
<col class="col-filed" />
|
|
559
716
|
<col class="col-actions" />
|
|
560
717
|
</colgroup>
|
|
561
718
|
<thead>
|
|
562
719
|
<tr>
|
|
563
|
-
{/* The
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
<th>
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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>
|
|
730
|
+
<th scope="col">Status</th>
|
|
731
|
+
<th class="cell-filed" scope="col">Filed</th>
|
|
732
|
+
<th scope="col">Actions</th>
|
|
570
733
|
</tr>
|
|
571
734
|
</thead>
|
|
572
735
|
<tbody>
|
|
573
|
-
{
|
|
574
|
-
<EntryRowView key={entry.id} entry={entry}
|
|
736
|
+
{group.entries.map((entry) => (
|
|
737
|
+
<EntryRowView key={entry.id} entry={entry} />
|
|
575
738
|
))}
|
|
576
739
|
</tbody>
|
|
577
740
|
</table>
|
|
578
741
|
</div>
|
|
579
|
-
</
|
|
742
|
+
</section>
|
|
580
743
|
);
|
|
581
744
|
}
|
|
582
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
|
+
|
|
583
752
|
/**
|
|
584
|
-
* One row. The
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
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.
|
|
592
761
|
*/
|
|
593
|
-
function EntryRowView({ entry
|
|
762
|
+
function EntryRowView({ entry }: { entry: EntryRow }) {
|
|
594
763
|
const title = entryTitle(entry);
|
|
764
|
+
const filed = filedStamp(entry.created_at);
|
|
595
765
|
const approve = entry.status === "draft";
|
|
596
766
|
const reject = entry.status === "draft" || entry.status === "approved";
|
|
597
767
|
return (
|
|
598
768
|
<tr>
|
|
599
769
|
<td class="cell-select">
|
|
600
770
|
{approve ? (
|
|
601
|
-
<
|
|
771
|
+
<label class="checkbox-label">
|
|
772
|
+
<input type="checkbox" name="id" value={entry.id} data-name={entry.id} aria-label={`Select ${entry.file}`} />
|
|
773
|
+
</label>
|
|
602
774
|
) : null}
|
|
603
775
|
</td>
|
|
604
|
-
<td
|
|
776
|
+
<td>
|
|
605
777
|
<div class="entry-head">
|
|
606
|
-
<
|
|
607
|
-
|
|
778
|
+
<a class="entry-link truncate" href={`/entries/${entry.id}`}>{title}</a>
|
|
779
|
+
<span class="entry-file truncate">{entry.file}</span>
|
|
608
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}
|
|
609
784
|
</td>
|
|
610
|
-
<td
|
|
611
|
-
<span class={`tag ${STATUS_TAG[entry.status]}`}>
|
|
785
|
+
<td>
|
|
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>
|
|
612
793
|
</td>
|
|
613
|
-
<td class="cell-file" title={entry.file}>{entry.file}</td>
|
|
614
794
|
<td class="cell-actions">
|
|
615
795
|
{approve ? (
|
|
616
796
|
<button
|
|
617
797
|
type="button"
|
|
618
|
-
class="btn btn-icon"
|
|
619
|
-
style="color: var(--success)"
|
|
798
|
+
class="btn btn-icon action-approve"
|
|
620
799
|
aria-label={`Approve ${entry.file}`}
|
|
621
|
-
title="Approve"
|
|
622
800
|
hx-post={`/entries/${entry.id}/approve`}
|
|
623
801
|
hx-target="#entries-list"
|
|
624
802
|
hx-swap="innerHTML"
|
|
625
803
|
hx-indicator="#entries-loading"
|
|
626
804
|
hx-disabled-elt="this"
|
|
627
805
|
>
|
|
628
|
-
<svg
|
|
629
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
630
|
-
<path d="M5 8l2 2 4-4" />
|
|
631
|
-
</svg>
|
|
806
|
+
<svg aria-hidden="true"><use href="#i-check" /></svg>
|
|
632
807
|
</button>
|
|
633
808
|
) : (
|
|
634
809
|
// The approve slot stays occupied on every non-draft row: a double-click's second click
|
|
635
810
|
// lands on the disabled button (pointer-events none) rather than the next slot.
|
|
636
|
-
<button type="button" class="btn btn-icon
|
|
637
|
-
<svg
|
|
638
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
639
|
-
<path d="M5 8l2 2 4-4" />
|
|
640
|
-
</svg>
|
|
811
|
+
<button type="button" class="btn btn-icon action-approve" aria-label={`Approve ${entry.file}`} disabled>
|
|
812
|
+
<svg aria-hidden="true"><use href="#i-check" /></svg>
|
|
641
813
|
</button>
|
|
642
814
|
)}
|
|
643
815
|
{reject ? (
|
|
644
816
|
<button
|
|
645
817
|
type="button"
|
|
646
|
-
class="btn btn-icon"
|
|
647
|
-
style="color: var(--danger)"
|
|
818
|
+
class="btn btn-icon action-reject"
|
|
648
819
|
aria-label={`Reject ${entry.file}`}
|
|
649
|
-
title="Reject"
|
|
650
820
|
hx-post={`/entries/${entry.id}/reject`}
|
|
651
821
|
hx-target="#entries-list"
|
|
652
822
|
hx-swap="innerHTML"
|
|
653
823
|
hx-indicator="#entries-loading"
|
|
654
824
|
hx-disabled-elt="this"
|
|
655
825
|
>
|
|
656
|
-
<svg
|
|
657
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
658
|
-
<path d="M10 6L6 10M6 6l4 4" />
|
|
659
|
-
</svg>
|
|
826
|
+
<svg aria-hidden="true"><use href="#i-x" /></svg>
|
|
660
827
|
</button>
|
|
661
828
|
) : (
|
|
662
829
|
// The reject slot stays occupied on applied and rejected rows for the same guard.
|
|
663
|
-
<button type="button" class="btn btn-icon
|
|
664
|
-
<svg
|
|
665
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
666
|
-
<path d="M10 6L6 10M6 6l4 4" />
|
|
667
|
-
</svg>
|
|
830
|
+
<button type="button" class="btn btn-icon action-reject" aria-label={`Reject ${entry.file}`} disabled>
|
|
831
|
+
<svg aria-hidden="true"><use href="#i-x" /></svg>
|
|
668
832
|
</button>
|
|
669
833
|
)}
|
|
670
|
-
<a class="btn btn-icon" href={`/entries/${entry.id}`} aria-label={`Edit ${entry.file}`}
|
|
671
|
-
<svg
|
|
672
|
-
<path d="M11.5 2.5l2 2L5 13H3v-2z" />
|
|
673
|
-
</svg>
|
|
834
|
+
<a class="btn btn-icon" href={`/entries/${entry.id}`} aria-label={`Edit ${entry.file}`}>
|
|
835
|
+
<svg aria-hidden="true"><use href="#i-edit" /></svg>
|
|
674
836
|
</a>
|
|
675
837
|
</td>
|
|
676
838
|
</tr>
|
|
677
839
|
);
|
|
678
840
|
}
|
|
679
841
|
|
|
680
|
-
/** The
|
|
681
|
-
|
|
842
|
+
/** The saved theme, restored before the stylesheet, or a light-theme visitor sees a dark flash. */
|
|
843
|
+
const THEME_RESTORE = raw(
|
|
844
|
+
'const saved = localStorage.getItem("cloudy-theme"); if (saved) document.documentElement.dataset.theme = saved;',
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
/** The head shared by every full page: the three stylesheets in order, then htmx and the dashboard module. */
|
|
848
|
+
function PageHead({ title }: { title: string }) {
|
|
682
849
|
return (
|
|
683
|
-
<
|
|
684
|
-
<
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
850
|
+
<head>
|
|
851
|
+
<meta charset="utf-8" />
|
|
852
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
853
|
+
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
854
|
+
<link rel="icon" href="data:," />
|
|
855
|
+
<title>{title}</title>
|
|
856
|
+
<script>{THEME_RESTORE}</script>
|
|
857
|
+
<link rel="stylesheet" href="/static/tokens.css" />
|
|
858
|
+
<link rel="stylesheet" href="/static/components.css" />
|
|
859
|
+
<link rel="stylesheet" href="/static/cursor.css" />
|
|
860
|
+
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
861
|
+
<script src="/static/htmx.min.js"></script>
|
|
862
|
+
<script type="module" src="/static/dashboard.js"></script>
|
|
863
|
+
</head>
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** The sprite in the body; cursor.js creates its own canvas and nothing else may enter that slot. */
|
|
868
|
+
function IconSprite() {
|
|
869
|
+
return <>{raw(ICON_SPRITE)}</>;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** The two controller modules, at the end of the body, exactly as the wiring prescribes. */
|
|
873
|
+
function PageModules() {
|
|
874
|
+
return (
|
|
875
|
+
<>
|
|
876
|
+
<script type="module" src="/static/cursor.js"></script>
|
|
877
|
+
<script type="module" src="/static/ui.js"></script>
|
|
878
|
+
</>
|
|
700
879
|
);
|
|
701
880
|
}
|
|
702
881
|
|
|
703
|
-
/** 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. */
|
|
704
883
|
export function dashboardPage(
|
|
705
884
|
store: Store,
|
|
706
885
|
params: ListParams,
|
|
@@ -710,23 +889,22 @@ export function dashboardPage(
|
|
|
710
889
|
const vm = loadList(store, params);
|
|
711
890
|
return (
|
|
712
891
|
<html lang="en" data-theme="dark">
|
|
713
|
-
<
|
|
714
|
-
<meta charset="utf-8" />
|
|
715
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
716
|
-
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
717
|
-
<link rel="icon" href="data:," />
|
|
718
|
-
<title>Entries · {NAME}</title>
|
|
719
|
-
<link rel="stylesheet" href="/static/tokens.css" />
|
|
720
|
-
<link rel="stylesheet" href="/static/components.css" />
|
|
721
|
-
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
722
|
-
<script src="/static/htmx.min.js"></script>
|
|
723
|
-
<script src="/static/dashboard.js" defer></script>
|
|
724
|
-
</head>
|
|
892
|
+
<PageHead title={`Entries · ${NAME}`} />
|
|
725
893
|
<body>
|
|
726
|
-
<
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
894
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
895
|
+
<IconSprite />
|
|
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>
|
|
730
908
|
<FilterForm vm={vm} />
|
|
731
909
|
<div id="entries">
|
|
732
910
|
<div id="entries-loading" class="htmx-indicator" role="status">
|
|
@@ -734,51 +912,45 @@ export function dashboardPage(
|
|
|
734
912
|
Loading entries
|
|
735
913
|
</div>
|
|
736
914
|
<div id="entries-list">
|
|
737
|
-
<ListRegion vm={vm}
|
|
915
|
+
<ListRegion vm={vm} />
|
|
738
916
|
</div>
|
|
739
917
|
</div>
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
918
|
+
</section>
|
|
919
|
+
</Shell>
|
|
920
|
+
<div class="toast-stack" id="toast-stack"></div>
|
|
921
|
+
<PageModules />
|
|
743
922
|
</body>
|
|
744
923
|
</html>
|
|
745
924
|
);
|
|
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 (
|
|
756
935
|
<html lang="en" data-theme="dark">
|
|
757
|
-
<
|
|
758
|
-
<meta charset="utf-8" />
|
|
759
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
760
|
-
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
761
|
-
<link rel="icon" href="data:," />
|
|
762
|
-
<title>Sign in · {NAME}</title>
|
|
763
|
-
<link rel="stylesheet" href="/static/tokens.css" />
|
|
764
|
-
<link rel="stylesheet" href="/static/components.css" />
|
|
765
|
-
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
766
|
-
<script src="/static/htmx.min.js"></script>
|
|
767
|
-
<script src="/static/dashboard.js" defer></script>
|
|
768
|
-
</head>
|
|
936
|
+
<PageHead title={`Sign in · ${NAME}`} />
|
|
769
937
|
<body>
|
|
770
|
-
<
|
|
771
|
-
|
|
772
|
-
<
|
|
773
|
-
<
|
|
774
|
-
<div class="
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
</header>
|
|
778
|
-
{error !== undefined ? <DangerCallout title={error} /> : null}
|
|
779
|
-
<form method="post" action="/login" class="card login-card">
|
|
938
|
+
<IconSprite />
|
|
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">
|
|
780
945
|
{next !== undefined ? <input type="hidden" name="next" value={next} /> : null}
|
|
781
|
-
<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}
|
|
782
954
|
<div class="field">
|
|
783
955
|
<label class="field-label" for="password">Password</label>
|
|
784
956
|
<input
|
|
@@ -790,31 +962,50 @@ export function loginPage(next: string | undefined, error: string | undefined =
|
|
|
790
962
|
/>
|
|
791
963
|
</div>
|
|
792
964
|
<div class="login-actions">
|
|
793
|
-
<button class="btn btn-
|
|
965
|
+
<button class="btn btn-fill" type="submit">Sign in</button>
|
|
794
966
|
</div>
|
|
795
967
|
</div>
|
|
796
968
|
</form>
|
|
797
|
-
</
|
|
798
|
-
</
|
|
969
|
+
</div>
|
|
970
|
+
</main>
|
|
971
|
+
<PageModules />
|
|
799
972
|
</body>
|
|
800
973
|
</html>
|
|
801
974
|
);
|
|
802
975
|
}
|
|
803
976
|
|
|
804
|
-
/**
|
|
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
|
+
*/
|
|
805
982
|
export function entriesFragment(store: Store, params: ListParams, notice: ListNotice | undefined = undefined): JSX.Element {
|
|
806
|
-
|
|
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
|
+
);
|
|
807
994
|
}
|
|
808
995
|
|
|
809
996
|
// ---- Entry editor ----
|
|
810
997
|
|
|
811
998
|
export type EditorViewModel = {
|
|
812
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;
|
|
813
1002
|
readonly revisions: readonly RevisionRow[];
|
|
814
1003
|
readonly constraints: Constraints;
|
|
815
1004
|
/** The entry immediately before/after in the list's ascending-id walk, or null at the boundary. */
|
|
816
1005
|
readonly prevId: string | null;
|
|
817
1006
|
readonly nextId: string | null;
|
|
1007
|
+
/** The sidebar's queue badge, read once with the editor's own query. */
|
|
1008
|
+
readonly draftCount: number;
|
|
818
1009
|
};
|
|
819
1010
|
|
|
820
1011
|
/**
|
|
@@ -838,7 +1029,16 @@ export function loadEditor(store: Store, id: string): EditorViewModel | null {
|
|
|
838
1029
|
const entry = getEntry(store, id);
|
|
839
1030
|
if (entry === undefined) return null;
|
|
840
1031
|
const { prevId, nextId } = adjacentEntryIds(store, id);
|
|
841
|
-
|
|
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
|
+
};
|
|
842
1042
|
}
|
|
843
1043
|
|
|
844
1044
|
/** The tolerant parse for `entries.context`, mirroring `parseConstraints`: foreign rows may hold malformed JSON, and unparseable means no context rows, never a 500. */
|
|
@@ -857,12 +1057,17 @@ function parseContext(json: string): Record<string, unknown> {
|
|
|
857
1057
|
* The user-facing refusal messages, in the dashboard's own voice: name what failed, then the fix.
|
|
858
1058
|
* The store's refusal carries the values; only this function turns them into prose.
|
|
859
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
|
+
|
|
860
1065
|
export function formatSaveRefusal(refusal: Exclude<HumanSaveRefusal, { kind: "unknown" }>): string {
|
|
861
1066
|
switch (refusal.kind) {
|
|
862
1067
|
case "max_len":
|
|
863
1068
|
return `This text is ${refusal.length} characters; the length limit is ${refusal.maxLen}. Shorten it to ${refusal.maxLen} characters or fewer.`;
|
|
864
1069
|
case "placeholder":
|
|
865
|
-
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.`;
|
|
866
1071
|
case "empty":
|
|
867
1072
|
return "Nothing to save. Enter text, or reject the entry.";
|
|
868
1073
|
case "status":
|
|
@@ -902,11 +1107,8 @@ export function formatTransitionRefusal(
|
|
|
902
1107
|
function DangerCallout({ title, body }: { title: string; body?: string }) {
|
|
903
1108
|
return (
|
|
904
1109
|
<div class="callout callout-danger" role="alert">
|
|
905
|
-
<div class="callout-icon"
|
|
906
|
-
<svg
|
|
907
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
908
|
-
<path d="M10 6L6 10M6 6l4 4" />
|
|
909
|
-
</svg>
|
|
1110
|
+
<div class="callout-icon">
|
|
1111
|
+
<svg aria-hidden="true"><use href="#i-error" /></svg>
|
|
910
1112
|
</div>
|
|
911
1113
|
<div class="callout-content">
|
|
912
1114
|
<div class="callout-title">{title}</div>
|
|
@@ -916,248 +1118,486 @@ function DangerCallout({ title, body }: { title: string; body?: string }) {
|
|
|
916
1118
|
);
|
|
917
1119
|
}
|
|
918
1120
|
|
|
1121
|
+
function InfoCallout({ title, body }: { title: string; body?: string }) {
|
|
1122
|
+
return (
|
|
1123
|
+
<div class="callout callout-info">
|
|
1124
|
+
<div class="callout-icon">
|
|
1125
|
+
<svg aria-hidden="true"><use href="#i-info" /></svg>
|
|
1126
|
+
</div>
|
|
1127
|
+
<div class="callout-content">
|
|
1128
|
+
<div class="callout-title">{title}</div>
|
|
1129
|
+
{body !== undefined ? <div class="callout-body">{body}</div> : null}
|
|
1130
|
+
</div>
|
|
1131
|
+
</div>
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
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 }) {
|
|
1137
|
+
return (
|
|
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>
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
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
|
+
}
|
|
1160
|
+
return (
|
|
1161
|
+
<a class="btn btn-secondary" href={`/entries/${targetId}`} aria-label={label}>
|
|
1162
|
+
{icon}
|
|
1163
|
+
</a>
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
919
1167
|
/**
|
|
920
|
-
* The editor
|
|
921
|
-
*
|
|
922
|
-
* while the server-rendered value stays what the last save left: the submitted text on a refusal.
|
|
923
|
-
* The `view` marker tells the approve and reject routes which region to answer with: this region,
|
|
924
|
-
* or the list's. The buttons carry htmx only; the save button keeps the native submit.
|
|
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.
|
|
925
1170
|
*/
|
|
926
|
-
function
|
|
927
|
-
const
|
|
928
|
-
const
|
|
929
|
-
const reject = vm.entry.status === "draft" || vm.entry.status === "approved";
|
|
1171
|
+
function EditorHeader({ vm }: { vm: EditorViewModel }) {
|
|
1172
|
+
const entry = vm.entry;
|
|
1173
|
+
const filed = filedStamp(entry.created_at);
|
|
930
1174
|
return (
|
|
931
|
-
<
|
|
932
|
-
|
|
933
|
-
<
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
<input type="hidden" name="view" value="editor" />
|
|
945
|
-
<div class="card-header">
|
|
946
|
-
<div class="card-title editor-title">{entryTitle(vm.entry)}</div>
|
|
947
|
-
<span class={`tag ${STATUS_TAG[vm.entry.status]}`}>
|
|
948
|
-
{STATUS_LABEL[vm.entry.status]}
|
|
949
|
-
</span>
|
|
950
|
-
</div>
|
|
951
|
-
<div class="card-content">
|
|
952
|
-
<textarea id="editor-text" class="input" name="text" rows={10} aria-label="Entry text" hx-preserve="true">
|
|
953
|
-
{text}
|
|
954
|
-
</textarea>
|
|
955
|
-
<div class="editor-actions">
|
|
956
|
-
<button class="btn btn-primary" type="submit">Save text</button>
|
|
957
|
-
{approve ? (
|
|
958
|
-
<button
|
|
959
|
-
type="button"
|
|
960
|
-
class="btn btn-secondary"
|
|
961
|
-
hx-post={`/entries/${vm.entry.id}/approve`}
|
|
962
|
-
hx-target="#editor-region"
|
|
963
|
-
hx-swap="outerHTML"
|
|
964
|
-
hx-indicator="#editor-action-loading"
|
|
965
|
-
hx-disabled-elt="this"
|
|
966
|
-
>
|
|
967
|
-
Approve draft
|
|
968
|
-
</button>
|
|
969
|
-
) : vm.entry.status === "approved" ? (
|
|
970
|
-
// Same slot-occupancy guard as the list row: the second click of a double-click on
|
|
971
|
-
// the just-approving button would otherwise land on the Reject button that shifts
|
|
972
|
-
// into this slot in the swapped region.
|
|
973
|
-
<button type="button" class="btn btn-secondary" disabled>
|
|
974
|
-
Approve draft
|
|
975
|
-
</button>
|
|
976
|
-
) : null}
|
|
977
|
-
{reject ? (
|
|
978
|
-
<button
|
|
979
|
-
type="button"
|
|
980
|
-
class="btn btn-danger"
|
|
981
|
-
hx-post={`/entries/${vm.entry.id}/reject`}
|
|
982
|
-
hx-target="#editor-region"
|
|
983
|
-
hx-swap="outerHTML"
|
|
984
|
-
hx-indicator="#editor-action-loading"
|
|
985
|
-
hx-disabled-elt="this"
|
|
986
|
-
>
|
|
987
|
-
Reject
|
|
988
|
-
</button>
|
|
989
|
-
) : null}
|
|
990
|
-
<span id="editor-action-loading" class="htmx-indicator" role="status">
|
|
991
|
-
<span class="spinner" aria-hidden="true"></span>
|
|
992
|
-
Updating entry
|
|
993
|
-
</span>
|
|
994
|
-
</div>
|
|
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>
|
|
995
1188
|
</div>
|
|
996
|
-
</
|
|
997
|
-
<
|
|
998
|
-
|
|
1189
|
+
</div>
|
|
1190
|
+
<div class="toolbar">
|
|
1191
|
+
<EditorNavButton side="prev" targetId={vm.prevId} />
|
|
1192
|
+
<EditorNavButton side="next" targetId={vm.nextId} />
|
|
1193
|
+
</div>
|
|
1194
|
+
</header>
|
|
999
1195
|
);
|
|
1000
1196
|
}
|
|
1001
1197
|
|
|
1002
|
-
|
|
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);
|
|
1003
1229
|
return (
|
|
1004
|
-
<div class="card">
|
|
1230
|
+
<div class="card current-text-card" id="current-text">
|
|
1005
1231
|
<div class="card-header">
|
|
1006
|
-
<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>
|
|
1007
1236
|
</div>
|
|
1008
1237
|
<div class="card-content">
|
|
1009
|
-
{
|
|
1010
|
-
<
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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>
|
|
1343
|
+
</div>
|
|
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">
|
|
1016
1351
|
<span class={`tag tag-data ${STATUS_TAG[revision.status]}`}>{STATUS_LABEL[revision.status]}</span>
|
|
1017
|
-
<time class="
|
|
1352
|
+
<time class="timeline-time" datetime={new Date(revision.created_at).toISOString()}>
|
|
1018
1353
|
{new Date(revision.created_at).toLocaleString()}
|
|
1019
1354
|
</time>
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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}
|
|
1023
1361
|
</div>
|
|
1024
|
-
<div class="
|
|
1025
|
-
</
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
</
|
|
1362
|
+
<div class="timeline-body clamp-2">{revision.human_text}</div>
|
|
1363
|
+
</div>
|
|
1364
|
+
</li>
|
|
1365
|
+
))}
|
|
1366
|
+
</ol>
|
|
1367
|
+
)}
|
|
1368
|
+
</SupportSection>
|
|
1031
1369
|
);
|
|
1032
1370
|
}
|
|
1033
1371
|
|
|
1034
|
-
/**
|
|
1035
|
-
|
|
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 }) {
|
|
1036
1377
|
const maxLen = constraints.maxLen;
|
|
1037
1378
|
const placeholders = constraints.placeholders;
|
|
1038
1379
|
const tone = constraints.tone;
|
|
1039
1380
|
const hasAny = maxLen !== undefined || placeholders.length > 0 || tone !== undefined;
|
|
1040
1381
|
if (!hasAny) return null;
|
|
1041
1382
|
return (
|
|
1042
|
-
<div class="
|
|
1043
|
-
|
|
1044
|
-
<
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
<li class="placeholder-item" data-placeholder={placeholder} key={placeholder}>
|
|
1065
|
-
<span class={`placeholder-state${present ? " is-present" : ""}`}>
|
|
1066
|
-
{present ? "Present" : "Missing"}
|
|
1067
|
-
</span>
|
|
1068
|
-
<span class="placeholder-token">{"{" + placeholder + "}"}</span>
|
|
1069
|
-
</li>
|
|
1070
|
-
);
|
|
1071
|
-
})}
|
|
1072
|
-
</ul>
|
|
1073
|
-
</div>
|
|
1074
|
-
) : null}
|
|
1075
|
-
{tone !== undefined ? (
|
|
1076
|
-
<div class="constraint-row">
|
|
1077
|
-
<div class="constraint-label">Tone</div>
|
|
1078
|
-
<div class="constraint-value">{tone}</div>
|
|
1079
|
-
</div>
|
|
1080
|
-
) : null}
|
|
1081
|
-
</div>
|
|
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}
|
|
1082
1405
|
</div>
|
|
1083
1406
|
);
|
|
1084
1407
|
}
|
|
1085
1408
|
|
|
1086
1409
|
/**
|
|
1087
|
-
* The
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
* the
|
|
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.
|
|
1092
1416
|
*/
|
|
1093
|
-
function
|
|
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";
|
|
1094
1422
|
return (
|
|
1095
|
-
<
|
|
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" />
|
|
1096
1434
|
<div class="card-header">
|
|
1097
|
-
<div
|
|
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>
|
|
1098
1443
|
</div>
|
|
1099
1444
|
<div class="card-content">
|
|
1100
|
-
<
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
hx-
|
|
1109
|
-
hx-target="#
|
|
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"
|
|
1110
1455
|
hx-swap="outerHTML"
|
|
1456
|
+
hx-sync="this:replace"
|
|
1111
1457
|
>
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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"
|
|
1121
1474
|
>
|
|
1122
|
-
|
|
1123
|
-
</
|
|
1124
|
-
|
|
1125
|
-
<
|
|
1126
|
-
<
|
|
1127
|
-
|
|
1128
|
-
|
|
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>
|
|
1129
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>
|
|
1499
|
+
</div>
|
|
1500
|
+
</div>
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
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} />}
|
|
1130
1514
|
</div>
|
|
1131
1515
|
);
|
|
1132
1516
|
}
|
|
1133
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
|
+
|
|
1134
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. */
|
|
1135
1564
|
function isRenderableImage(item: string): boolean {
|
|
1136
1565
|
return item.startsWith("data:image/") || /^https?:\/\//.test(item);
|
|
1137
1566
|
}
|
|
1138
1567
|
|
|
1139
1568
|
/**
|
|
1140
|
-
* The images attached to an entry, one
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
* 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.
|
|
1145
1573
|
*/
|
|
1146
|
-
function
|
|
1574
|
+
function ImagesSection({ entry }: { entry: EntryRow }) {
|
|
1147
1575
|
const images = parseEntryImages(entry.images);
|
|
1148
1576
|
return (
|
|
1149
|
-
<
|
|
1150
|
-
<div class="
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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>
|
|
1154
1594
|
{images.length === 0 ? (
|
|
1155
1595
|
<p class="field-hint">No images yet.</p>
|
|
1156
1596
|
) : (
|
|
1157
|
-
<ul class="image-list">
|
|
1597
|
+
<ul class="dropzone-list image-list">
|
|
1158
1598
|
{images.map((item, index) =>
|
|
1159
1599
|
isRenderableImage(item) ? (
|
|
1160
|
-
<li
|
|
1600
|
+
<li key={index}>
|
|
1161
1601
|
<img class="entry-image" src={item} alt={`Image ${index + 1}`} loading="lazy" />
|
|
1162
1602
|
<form
|
|
1163
1603
|
class="image-remove"
|
|
@@ -1167,11 +1607,11 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
|
|
|
1167
1607
|
hx-target="#editor-view"
|
|
1168
1608
|
hx-swap="outerHTML"
|
|
1169
1609
|
>
|
|
1170
|
-
<button class="btn btn-
|
|
1610
|
+
<button class="btn btn-secondary btn-sm" type="submit">Remove</button>
|
|
1171
1611
|
</form>
|
|
1172
1612
|
</li>
|
|
1173
1613
|
) : (
|
|
1174
|
-
<li
|
|
1614
|
+
<li key={index}>
|
|
1175
1615
|
<p class="image-skip">Image {index + 1} not shown: unsupported source.</p>
|
|
1176
1616
|
</li>
|
|
1177
1617
|
),
|
|
@@ -1179,6 +1619,7 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
|
|
|
1179
1619
|
</ul>
|
|
1180
1620
|
)}
|
|
1181
1621
|
<form
|
|
1622
|
+
id="image-upload"
|
|
1182
1623
|
class="image-upload"
|
|
1183
1624
|
method="post"
|
|
1184
1625
|
action={`/entries/${entry.id}/images`}
|
|
@@ -1188,276 +1629,139 @@ function ImagesCard({ entry }: { entry: EntryRow }) {
|
|
|
1188
1629
|
hx-target="#editor-view"
|
|
1189
1630
|
hx-swap="outerHTML"
|
|
1190
1631
|
>
|
|
1191
|
-
<input class="input" type="file" name="file" accept="image/*" aria-label="Image file" />
|
|
1192
1632
|
<button class="btn btn-secondary btn-sm" type="submit" hx-disabled-elt="this">Add image</button>
|
|
1193
1633
|
</form>
|
|
1194
1634
|
</div>
|
|
1195
|
-
</
|
|
1635
|
+
</SupportSection>
|
|
1196
1636
|
);
|
|
1197
1637
|
}
|
|
1198
1638
|
|
|
1199
1639
|
/**
|
|
1200
1640
|
* 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.
|
|
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.
|
|
1202
1643
|
*/
|
|
1203
1644
|
function RepoValue({ repo }: { repo: string }) {
|
|
1204
1645
|
const link = parseRepoLink(repo);
|
|
1205
1646
|
if (link === null) return <>{repo}</>;
|
|
1206
1647
|
return (
|
|
1207
|
-
<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">
|
|
1208
1649
|
<svg class="repo-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
1209
1650
|
<path d={REPO_ICON[link.provider]} />
|
|
1210
1651
|
</svg>
|
|
1211
1652
|
<span>{link.path}</span>
|
|
1653
|
+
<span class="sr-only">{repo}</span>
|
|
1212
1654
|
</a>
|
|
1213
1655
|
);
|
|
1214
1656
|
}
|
|
1215
1657
|
|
|
1216
1658
|
/**
|
|
1217
|
-
* The
|
|
1218
|
-
*
|
|
1219
|
-
* the
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
const context = parseContext(entry.context);
|
|
1223
|
-
return (
|
|
1224
|
-
<div class="card">
|
|
1225
|
-
<div class="card-header">
|
|
1226
|
-
<div class="card-title">Context</div>
|
|
1227
|
-
</div>
|
|
1228
|
-
<div class="card-content">
|
|
1229
|
-
<table class="info-table">
|
|
1230
|
-
<tr>
|
|
1231
|
-
<td>Anchor</td>
|
|
1232
|
-
<td>{entry.anchor_text}</td>
|
|
1233
|
-
</tr>
|
|
1234
|
-
{Object.entries(context).map(([key, value]) => (
|
|
1235
|
-
<tr key={key}>
|
|
1236
|
-
<td>{key}</td>
|
|
1237
|
-
<td>{typeof value === "string" ? value : JSON.stringify(value)}</td>
|
|
1238
|
-
</tr>
|
|
1239
|
-
))}
|
|
1240
|
-
<tr>
|
|
1241
|
-
<td>Repo</td>
|
|
1242
|
-
<td><RepoValue repo={entry.repo} /></td>
|
|
1243
|
-
</tr>
|
|
1244
|
-
<tr>
|
|
1245
|
-
<td>File</td>
|
|
1246
|
-
<td>{entry.file}</td>
|
|
1247
|
-
</tr>
|
|
1248
|
-
</table>
|
|
1249
|
-
<details class="context-secondary">
|
|
1250
|
-
<summary>More</summary>
|
|
1251
|
-
<table class="info-table">
|
|
1252
|
-
<tr>
|
|
1253
|
-
<td>File hash</td>
|
|
1254
|
-
<td>{entry.file_hash}</td>
|
|
1255
|
-
</tr>
|
|
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>
|
|
1270
|
-
</div>
|
|
1271
|
-
</div>
|
|
1272
|
-
);
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
/**
|
|
1276
|
-
* The before-and-after of the entry's text, above the editor: what the anchored region reads now
|
|
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.
|
|
1282
|
-
*/
|
|
1283
|
-
function DiffView({ entry }: { entry: EntryRow }) {
|
|
1284
|
-
const after = entry.human_text ?? entry.agent_draft;
|
|
1285
|
-
// No text at all means nothing to compare: a filed entry whose agent never drafted, or an
|
|
1286
|
-
// approved entry whose text was never set.
|
|
1287
|
-
if (after === null) return null;
|
|
1288
|
-
const diff = diffWords(entry.anchor_text, after);
|
|
1289
|
-
return (
|
|
1290
|
-
<div class="diff-grid">
|
|
1291
|
-
<div class="card">
|
|
1292
|
-
<div class="card-header">
|
|
1293
|
-
<div class="card-title">Before</div>
|
|
1294
|
-
</div>
|
|
1295
|
-
<pre class="diff-code">
|
|
1296
|
-
<span class="diff-line">{entry.anchor_before}</span>
|
|
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>
|
|
1306
|
-
<span class="diff-line">{entry.anchor_after}</span>
|
|
1307
|
-
</pre>
|
|
1308
|
-
</div>
|
|
1309
|
-
<div class="card">
|
|
1310
|
-
<div class="card-header">
|
|
1311
|
-
<div class="card-title">After</div>
|
|
1312
|
-
</div>
|
|
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>
|
|
1322
|
-
</div>
|
|
1323
|
-
</div>
|
|
1324
|
-
);
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
/**
|
|
1328
|
-
* The editor view: the diff above the editor grid, one swap node. A save changes the diff's
|
|
1329
|
-
* after-side, so the save form targets this wrapper and its response re-renders the whole view;
|
|
1330
|
-
* 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.
|
|
1331
1664
|
*/
|
|
1332
1665
|
function EditorView({ vm, state }: { vm: EditorViewModel; state: EditorState | undefined }) {
|
|
1333
|
-
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;
|
|
1334
1670
|
return (
|
|
1335
1671
|
<div id="editor-view">
|
|
1336
|
-
<
|
|
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>
|
|
1337
1686
|
<div class="editor-grid">
|
|
1338
|
-
|
|
1339
|
-
|
|
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>
|
|
1340
1698
|
</div>
|
|
1341
|
-
<aside class="editor-rail">
|
|
1342
|
-
<ContextCard entry={vm.entry} />
|
|
1343
|
-
<ConstraintsCard constraints={vm.constraints} text={text} />
|
|
1344
|
-
<NotesCard vm={vm} />
|
|
1345
|
-
<ImagesCard entry={vm.entry} />
|
|
1346
|
-
</aside>
|
|
1347
1699
|
</div>
|
|
1348
1700
|
</div>
|
|
1349
1701
|
);
|
|
1350
1702
|
}
|
|
1351
1703
|
|
|
1352
|
-
/** The editor
|
|
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
|
-
|
|
1386
|
-
/** 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. */
|
|
1387
1705
|
export function editorPage(vm: EditorViewModel, state: EditorState | undefined = undefined, signedIn = false): JSX.Element {
|
|
1388
1706
|
return (
|
|
1389
1707
|
<html lang="en" data-theme="dark">
|
|
1390
|
-
<
|
|
1391
|
-
<meta charset="utf-8" />
|
|
1392
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1393
|
-
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
1394
|
-
<link rel="icon" href="data:," />
|
|
1395
|
-
<title>Edit entry · {NAME}</title>
|
|
1396
|
-
<link rel="stylesheet" href="/static/tokens.css" />
|
|
1397
|
-
<link rel="stylesheet" href="/static/components.css" />
|
|
1398
|
-
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
1399
|
-
<script src="/static/htmx.min.js"></script>
|
|
1400
|
-
<script src="/static/dashboard.js" defer></script>
|
|
1401
|
-
</head>
|
|
1708
|
+
<PageHead title={`Edit entry · ${NAME}`} />
|
|
1402
1709
|
<body>
|
|
1403
|
-
<
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
</header>
|
|
1710
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
1711
|
+
<IconSprite />
|
|
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} />
|
|
1411
1717
|
<EditorView vm={vm} state={state} />
|
|
1412
|
-
<EditorNav prevId={vm.prevId} nextId={vm.nextId} />
|
|
1413
1718
|
{/* The one live region: announcements on constraint-state flips only, so a screen reader
|
|
1414
1719
|
is not flooded with a per-keystroke value. Lives outside the swap region. */}
|
|
1415
|
-
<span id="editor-live" class="
|
|
1416
|
-
</
|
|
1417
|
-
</
|
|
1720
|
+
<span id="editor-live" class="sr-only" aria-live="polite"></span>
|
|
1721
|
+
</div>
|
|
1722
|
+
</Shell>
|
|
1723
|
+
<div class="toast-stack" id="toast-stack"></div>
|
|
1724
|
+
<PageModules />
|
|
1418
1725
|
</body>
|
|
1419
1726
|
</html>
|
|
1420
1727
|
);
|
|
1421
1728
|
}
|
|
1422
1729
|
|
|
1423
|
-
/** The editor region
|
|
1424
|
-
export function editorFragment(vm: EditorViewModel, state: EditorState | undefined = undefined): JSX.Element {
|
|
1425
|
-
return <EditorRegion vm={vm} state={state} />;
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
/** 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. */
|
|
1429
1731
|
export function editorViewFragment(vm: EditorViewModel, state: EditorState | undefined = undefined): JSX.Element {
|
|
1430
|
-
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
|
+
);
|
|
1431
1739
|
}
|
|
1432
1740
|
|
|
1433
|
-
/** The
|
|
1434
|
-
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";
|
|
1435
1744
|
return (
|
|
1436
1745
|
<html lang="en" data-theme="dark">
|
|
1437
|
-
<
|
|
1438
|
-
<meta charset="utf-8" />
|
|
1439
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1440
|
-
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
1441
|
-
<link rel="icon" href="data:," />
|
|
1442
|
-
<title>Entry not found · {NAME}</title>
|
|
1443
|
-
<link rel="stylesheet" href="/static/tokens.css" />
|
|
1444
|
-
<link rel="stylesheet" href="/static/components.css" />
|
|
1445
|
-
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
1446
|
-
<script src="/static/htmx.min.js"></script>
|
|
1447
|
-
<script src="/static/dashboard.js" defer></script>
|
|
1448
|
-
</head>
|
|
1746
|
+
<PageHead title={`${entry ? "Entry" : "Page"} not found · ${NAME}`} />
|
|
1449
1747
|
<body>
|
|
1450
|
-
<
|
|
1451
|
-
|
|
1452
|
-
<
|
|
1453
|
-
<
|
|
1454
|
-
<
|
|
1455
|
-
<
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1748
|
+
<IconSprite />
|
|
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>
|
|
1760
|
+
</div>
|
|
1761
|
+
</div>
|
|
1762
|
+
</div>
|
|
1763
|
+
</main>
|
|
1764
|
+
<PageModules />
|
|
1461
1765
|
</body>
|
|
1462
1766
|
</html>
|
|
1463
1767
|
);
|
|
@@ -1466,7 +1770,7 @@ export function notFoundPage(signedIn = false): JSX.Element {
|
|
|
1466
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. */
|
|
1467
1771
|
export function entryGoneFragment(): JSX.Element {
|
|
1468
1772
|
return (
|
|
1469
|
-
<div id="editor-region">
|
|
1773
|
+
<div id="editor-region" tabindex={-1}>
|
|
1470
1774
|
<DangerCallout title="This entry no longer exists" body="Return to the list to find it." />
|
|
1471
1775
|
<a href="/" class="btn btn-secondary">Back to entries</a>
|
|
1472
1776
|
</div>
|
|
@@ -1491,6 +1795,8 @@ export type StyleGuideViewModel = {
|
|
|
1491
1795
|
readonly hasRow: boolean;
|
|
1492
1796
|
/** The stored section, or the empty defaults when there is no row yet. */
|
|
1493
1797
|
readonly section: StyleGuideInput;
|
|
1798
|
+
/** The sidebar's queue badge, read with the page's own store access. */
|
|
1799
|
+
readonly draftCount: number;
|
|
1494
1800
|
};
|
|
1495
1801
|
|
|
1496
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. */
|
|
@@ -1514,6 +1820,7 @@ export function loadStyleGuide(store: Store, projectQuery: string | undefined):
|
|
|
1514
1820
|
unknown,
|
|
1515
1821
|
hasRow: section !== null,
|
|
1516
1822
|
section: section ?? { markdown: "", bannedWords: [], glossary: {} },
|
|
1823
|
+
draftCount: countEntriesByStatus(store).draft,
|
|
1517
1824
|
};
|
|
1518
1825
|
}
|
|
1519
1826
|
|
|
@@ -1540,48 +1847,29 @@ function sectionToForm(section: StyleGuideInput): StyleGuideForm {
|
|
|
1540
1847
|
};
|
|
1541
1848
|
}
|
|
1542
1849
|
|
|
1543
|
-
|
|
1544
|
-
return (
|
|
1545
|
-
<div class="callout callout-info">
|
|
1546
|
-
<div class="callout-icon" style="color: var(--info)">
|
|
1547
|
-
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
1548
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
1549
|
-
<path d="M8 7.5v3.5M8 5.5v.01" />
|
|
1550
|
-
</svg>
|
|
1551
|
-
</div>
|
|
1552
|
-
<div class="callout-content">
|
|
1553
|
-
<div class="callout-title">{title}</div>
|
|
1554
|
-
{body !== undefined ? <div class="callout-body">{body}</div> : null}
|
|
1555
|
-
</div>
|
|
1556
|
-
</div>
|
|
1557
|
-
);
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
/** 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. */
|
|
1561
1851
|
function StyleGuideProjects({ vm }: { vm: StyleGuideViewModel }) {
|
|
1562
1852
|
const activeSlug = vm.unknown ? null : vm.projectSlug;
|
|
1563
1853
|
return (
|
|
1564
|
-
<nav class="style-guide-projects" aria-label="Style guide sections">
|
|
1565
|
-
<
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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>
|
|
1574
1866
|
</nav>
|
|
1575
1867
|
);
|
|
1576
1868
|
}
|
|
1577
1869
|
|
|
1578
1870
|
function ProjectSectionLink({ href, label, active }: { href: string; label: string; active: boolean }) {
|
|
1579
1871
|
return (
|
|
1580
|
-
<a
|
|
1581
|
-
class={`btn btn-sm ${active ? "btn-primary" : "btn-secondary"}`}
|
|
1582
|
-
href={href}
|
|
1583
|
-
aria-current={active ? "page" : undefined}
|
|
1584
|
-
>
|
|
1872
|
+
<a class={`tab${active ? " active" : ""}`} href={href} aria-current={active ? "page" : undefined}>
|
|
1585
1873
|
{label}
|
|
1586
1874
|
</a>
|
|
1587
1875
|
);
|
|
@@ -1607,11 +1895,8 @@ function StyleGuideRegion({ vm, state }: { vm: StyleGuideViewModel; state: Style
|
|
|
1607
1895
|
<div id="style-guide-region">
|
|
1608
1896
|
{state?.notice.kind === "saved" ? (
|
|
1609
1897
|
<div class="callout callout-success" role="status">
|
|
1610
|
-
<div class="callout-icon"
|
|
1611
|
-
<svg
|
|
1612
|
-
<circle cx="8" cy="8" r="6.5" />
|
|
1613
|
-
<path d="M5 8l2 2 4-4" />
|
|
1614
|
-
</svg>
|
|
1898
|
+
<div class="callout-icon">
|
|
1899
|
+
<svg aria-hidden="true"><use href="#i-success" /></svg>
|
|
1615
1900
|
</div>
|
|
1616
1901
|
<div class="callout-content">
|
|
1617
1902
|
<div class="callout-title">Guide saved</div>
|
|
@@ -1630,81 +1915,59 @@ function StyleGuideRegion({ vm, state }: { vm: StyleGuideViewModel; state: Style
|
|
|
1630
1915
|
body="Your save creates it."
|
|
1631
1916
|
/>
|
|
1632
1917
|
) : null}
|
|
1633
|
-
<
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
</div>
|
|
1647
|
-
<div class="card-content">
|
|
1648
|
-
<div class="field">
|
|
1649
|
-
<label class="field-label" for="style-guide-markdown">Markdown</label>
|
|
1650
|
-
<textarea class="input" id="style-guide-markdown" name="markdown" rows={10} aria-label="Guide markdown">
|
|
1651
|
-
{formValues.markdown}
|
|
1652
|
-
</textarea>
|
|
1653
|
-
{vm.hasRow && vm.section.markdown === "" ? <p class="field-hint">No markdown yet.</p> : null}
|
|
1654
|
-
</div>
|
|
1655
|
-
<div class="field">
|
|
1656
|
-
<label class="field-label" for="style-guide-banned">Banned words</label>
|
|
1657
|
-
<input class="input" id="style-guide-banned" name="banned_words" type="text" value={formValues.bannedWordsCsv} />
|
|
1658
|
-
<p class="field-hint">Comma-separated. Extra whitespace and duplicates are dropped.</p>
|
|
1659
|
-
{vm.hasRow && vm.section.bannedWords.length === 0 ? <p class="field-hint">No banned words yet.</p> : null}
|
|
1660
|
-
</div>
|
|
1661
|
-
<div class="field">
|
|
1662
|
-
<label class="field-label" for="style-guide-glossary">Glossary</label>
|
|
1663
|
-
<textarea class="input" id="style-guide-glossary" name="glossary" rows={6} aria-label="Glossary entries">
|
|
1664
|
-
{formValues.glossaryText}
|
|
1665
|
-
</textarea>
|
|
1666
|
-
<p class="field-hint">One key: value pair per line. A line without a colon is refused.</p>
|
|
1667
|
-
{vm.hasRow && Object.keys(vm.section.glossary).length === 0 ? (
|
|
1668
|
-
<p class="field-hint">No glossary entries yet.</p>
|
|
1669
|
-
) : null}
|
|
1670
|
-
</div>
|
|
1671
|
-
<div class="editor-actions">
|
|
1672
|
-
<button class="btn btn-primary" type="submit" hx-disabled-elt="this">Save guide</button>
|
|
1673
|
-
<span id="style-guide-loading" class="htmx-indicator" role="status">
|
|
1674
|
-
<span class="spinner" aria-hidden="true"></span>
|
|
1675
|
-
Saving guide
|
|
1676
|
-
</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>
|
|
1677
1931
|
</div>
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
<
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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>
|
|
1700
1963
|
</div>
|
|
1701
|
-
</
|
|
1964
|
+
</form>
|
|
1702
1965
|
</div>
|
|
1703
|
-
</
|
|
1966
|
+
</div>
|
|
1704
1967
|
);
|
|
1705
1968
|
}
|
|
1706
1969
|
|
|
1707
|
-
/** 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. */
|
|
1708
1971
|
export function styleGuidePage(
|
|
1709
1972
|
vm: StyleGuideViewModel,
|
|
1710
1973
|
state: StyleGuideEditorState | undefined = undefined,
|
|
@@ -1712,38 +1975,29 @@ export function styleGuidePage(
|
|
|
1712
1975
|
): JSX.Element {
|
|
1713
1976
|
return (
|
|
1714
1977
|
<html lang="en" data-theme="dark">
|
|
1715
|
-
<
|
|
1716
|
-
<meta charset="utf-8" />
|
|
1717
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1718
|
-
{/* The empty data: URL kills the browser's /favicon.ico probe, which would 404 under /static. */}
|
|
1719
|
-
<link rel="icon" href="data:," />
|
|
1720
|
-
<title>Style guide · {NAME}</title>
|
|
1721
|
-
<link rel="stylesheet" href="/static/tokens.css" />
|
|
1722
|
-
<link rel="stylesheet" href="/static/components.css" />
|
|
1723
|
-
<link rel="stylesheet" href="/static/dashboard.css" />
|
|
1724
|
-
<script src="/static/htmx.min.js"></script>
|
|
1725
|
-
<script src="/static/dashboard.js" defer></script>
|
|
1726
|
-
</head>
|
|
1978
|
+
<PageHead title={`Style guide · ${NAME}`} />
|
|
1727
1979
|
<body>
|
|
1728
|
-
<
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1980
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
1981
|
+
<IconSprite />
|
|
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>
|
|
1733
1986
|
<h1 class="page-title">Style guide</h1>
|
|
1734
|
-
<p class="
|
|
1735
|
-
</
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
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>
|
|
1994
|
+
<PageModules />
|
|
1741
1995
|
</body>
|
|
1742
1996
|
</html>
|
|
1743
1997
|
);
|
|
1744
1998
|
}
|
|
1745
1999
|
|
|
1746
|
-
/** 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. */
|
|
1747
2001
|
export function styleGuideRegionFragment(vm: StyleGuideViewModel, state: StyleGuideEditorState | undefined = undefined): JSX.Element {
|
|
1748
2002
|
return <StyleGuideRegion vm={vm} state={state} />;
|
|
1749
2003
|
}
|