@glw907/cairn-cms 0.54.0 → 0.55.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/CHANGELOG.md +27 -0
- package/dist/components/ConceptList.svelte +210 -73
- package/dist/components/ConceptList.svelte.d.ts +6 -4
- package/dist/components/cairn-admin.css +50 -0
- package/dist/content/manifest.d.ts +1 -0
- package/dist/content/manifest.js +6 -0
- package/dist/delivery/content-index.js +1 -1
- package/dist/delivery/data.d.ts +1 -1
- package/dist/delivery/data.js +1 -1
- package/dist/sveltekit/content-routes.d.ts +3 -0
- package/dist/sveltekit/content-routes.js +10 -5
- package/package.json +1 -1
- package/src/lib/components/ConceptList.svelte +210 -73
- package/src/lib/content/manifest.ts +7 -0
- package/src/lib/delivery/content-index.ts +1 -1
- package/src/lib/delivery/data.ts +1 -1
- package/src/lib/sveltekit/content-routes.ts +13 -5
- /package/dist/{delivery → content}/excerpt.d.ts +0 -0
- /package/dist/{delivery → content}/excerpt.js +0 -0
- /package/src/lib/{delivery → content}/excerpt.ts +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are recorded here, most recent first.
|
|
4
4
|
|
|
5
|
+
## 0.55.0
|
|
6
|
+
|
|
7
|
+
The office list rises to the gold standard. The post and page list gains a triage filter layer and
|
|
8
|
+
self-describing rows, so a concept with a handful of entries reads as content rather than a few bare
|
|
9
|
+
titles.
|
|
10
|
+
|
|
11
|
+
Above the list, a triage bar filters by publish state in the admin's segmented check-and-tint
|
|
12
|
+
grammar: All, Pending edits (the entries on a `cairn/` branch, whether branch-only or live with held
|
|
13
|
+
edits), and Published, each with a live count, plus an orthogonal Hidden toggle for the draft
|
|
14
|
+
entries. The counts come from the loaded set, so they are exact, and the filtering runs client-side
|
|
15
|
+
over the entries already in hand. Search composes with the active filter.
|
|
16
|
+
|
|
17
|
+
Each row now describes itself. A summary line sits under the title, drawn from the entry's
|
|
18
|
+
description or, lacking one, a short excerpt of its body. The Edited badge tints in the brand violet
|
|
19
|
+
as the one state to act on, mirroring the "Publish site (N)" count; Hidden reads as a de-emphasized
|
|
20
|
+
row with an eye-off tag rather than a competing badge; and the foot of the list carries a quiet
|
|
21
|
+
"New" row so a short list always shows its next step. A concept with no entries centers its empty
|
|
22
|
+
state on the page.
|
|
23
|
+
|
|
24
|
+
One data change feeds the rows: the content manifest now indexes a per-entry `summary`, built by the
|
|
25
|
+
same excerpt helper the public delivery already uses. Because the manifest is verified whole-string,
|
|
26
|
+
a site's committed manifest is stale until it is regenerated once.
|
|
27
|
+
|
|
28
|
+
Consumers must: regenerate the content manifest (`npm run cairn:manifest` or `npx cairn-manifest`,
|
|
29
|
+
then commit). The `cairnManifest` build fails closed until the regenerated manifest with the new
|
|
30
|
+
`summary` keys is committed.
|
|
31
|
+
|
|
5
32
|
## 0.54.0
|
|
6
33
|
|
|
7
34
|
The editor takes the shell. On an edit route the page is now one context, the desk: the edit page's
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
One concept's list view
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
One concept's list view, dressed to the office gold standard. A triage bar partitions by publish
|
|
4
|
+
state (a bordered segmented control with live counts) beside an orthogonal Hidden toggle. The list
|
|
5
|
+
is an enriched sortable table: each row carries a title with a muted summary sub-line, the date, the
|
|
6
|
+
publish-state badge, and a delete action. A draft row de-emphasizes and carries an eye-off Hidden
|
|
7
|
+
tag by the title. A trailing New row at the foot of the card opens the same create dialog as the
|
|
8
|
+
header button. Filtering, sorting, and paging run over the loaded entries in component state.
|
|
7
9
|
-->
|
|
8
10
|
<script lang="ts">
|
|
9
11
|
import { slugify } from '../content/ids.js';
|
|
@@ -12,6 +14,7 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
12
14
|
import DeleteDialog from './DeleteDialog.svelte';
|
|
13
15
|
import CairnLogo from './CairnLogo.svelte';
|
|
14
16
|
import { SearchIcon, ArrowUpIcon, ArrowDownIcon, ChevronsUpDownIcon, ChevronLeftIcon, ChevronRightIcon, PlusIcon, Trash2Icon } from './admin-icons.js';
|
|
17
|
+
import EyeOffIcon from '@lucide/svelte/icons/eye-off';
|
|
15
18
|
|
|
16
19
|
interface Props {
|
|
17
20
|
/** The list load's data: the concept, its entries, and any inline or form errors. */
|
|
@@ -31,7 +34,14 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
31
34
|
);
|
|
32
35
|
|
|
33
36
|
type SortKey = 'title' | 'date';
|
|
37
|
+
// The triage runs on two independent axes. A pick-one publish-state partition (`all` passes
|
|
38
|
+
// everything, `pending` is new + edited, `published` is live-as-is) and a separate Hidden
|
|
39
|
+
// toggle that composes with it. They are orthogonal: a published-but-hidden entry is on main
|
|
40
|
+
// (so it counts as Published) and also hidden from the public site (so it counts as Hidden).
|
|
41
|
+
type Partition = 'all' | 'pending' | 'published';
|
|
34
42
|
let query = $state('');
|
|
43
|
+
let partition = $state<Partition>('all');
|
|
44
|
+
let hiddenOnly = $state(false);
|
|
35
45
|
let sortKey = $state<SortKey>('date');
|
|
36
46
|
// Newest first by default: a dated concept reads most-recent-on-top, the usual CMS convention.
|
|
37
47
|
let sortAsc = $state(false);
|
|
@@ -45,10 +55,82 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
45
55
|
return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed);
|
|
46
56
|
}
|
|
47
57
|
|
|
58
|
+
// Triage counts over the full loaded set, each axis counted independently. Pending is new +
|
|
59
|
+
// edited (status !== 'published'); Published is live-as-is (status === 'published'); Hidden is
|
|
60
|
+
// the draft rows. The axes overlap: a published-but-hidden entry counts in BOTH Published and
|
|
61
|
+
// Hidden. All is the unconditional total.
|
|
62
|
+
const counts = $derived({
|
|
63
|
+
all: data.entries.length,
|
|
64
|
+
pending: data.entries.filter((e) => e.status !== 'published').length,
|
|
65
|
+
published: data.entries.filter((e) => e.status === 'published').length,
|
|
66
|
+
hidden: data.entries.filter((e) => e.draft).length,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The three publish-state segments, in display order. Each names its partition value, its label,
|
|
70
|
+
// and the count axis it shows; the markup loops this so the segments share one block.
|
|
71
|
+
const segments: { value: Partition; label: string; count: () => number }[] = [
|
|
72
|
+
{ value: 'all', label: 'All', count: () => counts.all },
|
|
73
|
+
{ value: 'pending', label: 'Pending edits', count: () => counts.pending },
|
|
74
|
+
{ value: 'published', label: 'Published', count: () => counts.published },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
function matchesPartition(entry: EntrySummary): boolean {
|
|
78
|
+
switch (partition) {
|
|
79
|
+
case 'pending':
|
|
80
|
+
return entry.status !== 'published';
|
|
81
|
+
case 'published':
|
|
82
|
+
return entry.status === 'published';
|
|
83
|
+
default:
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Compose the partition and the Hidden axis with the search query; sort and paging run
|
|
89
|
+
// downstream. Hidden is orthogonal: when on, it narrows the partition to its draft rows.
|
|
48
90
|
const filtered = $derived(
|
|
49
|
-
data.entries.filter(
|
|
91
|
+
data.entries.filter(
|
|
92
|
+
(e) =>
|
|
93
|
+
matchesPartition(e) &&
|
|
94
|
+
(!hiddenOnly || e.draft === true) &&
|
|
95
|
+
e.title.toLowerCase().includes(query.trim().toLowerCase()),
|
|
96
|
+
),
|
|
50
97
|
);
|
|
51
98
|
|
|
99
|
+
function setPartition(next: Partition) {
|
|
100
|
+
partition = next;
|
|
101
|
+
page = 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toggleHidden() {
|
|
105
|
+
hiddenOnly = !hiddenOnly;
|
|
106
|
+
page = 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function clearSearch() {
|
|
110
|
+
query = '';
|
|
111
|
+
page = 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The triage controls dress to the established footer grammar (the design system's segmented /
|
|
115
|
+
// check-and-tint recipe). Each helper returns a verbatim Tailwind string so the admin CSS
|
|
116
|
+
// build's @source scan reads the utilities whole. The scoped button reset (cairn-admin.css)
|
|
117
|
+
// already strips UA chrome from these bare buttons.
|
|
118
|
+
//
|
|
119
|
+
// A segment of the bordered publish-state control: the shared group border carries the pick-one
|
|
120
|
+
// semantics, so a segment stays borderless; the active one tints and bolds.
|
|
121
|
+
function segButtonClass(pressed: boolean): string {
|
|
122
|
+
return `inline-flex items-center gap-1.5 px-3 py-1 text-[0.8125rem] font-normal ${pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
|
|
123
|
+
}
|
|
124
|
+
// The standalone Hidden toggle: rounded, transparent until hover, check-and-tint when pressed.
|
|
125
|
+
function hiddenToggleClass(pressed: boolean): string {
|
|
126
|
+
return `inline-flex items-center gap-1.5 rounded-lg px-3 py-1 text-[0.8125rem] font-normal hover:bg-base-content/[0.06] ${pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
|
|
127
|
+
}
|
|
128
|
+
// Hidden is a row treatment: a draft row de-emphasizes its TITLE by opacity (the title is
|
|
129
|
+
// high-contrast base-content, so it stays above the AA text floor when dimmed). The already-muted
|
|
130
|
+
// summary line is left at full strength: stacking opacity on muted text drops it below 4.5:1 in
|
|
131
|
+
// the light theme, so the dimmed title plus the eye-off tag carry the "hidden" read instead.
|
|
132
|
+
const draftDim = 'opacity-[0.62]';
|
|
133
|
+
|
|
52
134
|
// Sort key for one entry: the lowercased title, or the ISO date string (lexical order is
|
|
53
135
|
// chronological). A null date sorts as the empty string.
|
|
54
136
|
function sortValue(entry: EntrySummary): string {
|
|
@@ -116,6 +198,12 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
116
198
|
);
|
|
117
199
|
</script>
|
|
118
200
|
|
|
201
|
+
<!-- The non-color selected cue for the triage controls (WCAG 1.4.1): a small check glyph that
|
|
202
|
+
renders only inside the active segment or toggle, so hue never carries the state alone. -->
|
|
203
|
+
{#snippet check()}
|
|
204
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
|
205
|
+
{/snippet}
|
|
206
|
+
|
|
119
207
|
<header class="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
|
120
208
|
<h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.label}</h1>
|
|
121
209
|
<div class="flex items-center gap-3 sm:flex-1 sm:flex-wrap sm:justify-end">
|
|
@@ -159,82 +247,131 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
159
247
|
</div>
|
|
160
248
|
{/if}
|
|
161
249
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
<
|
|
172
|
-
|
|
250
|
+
{#if data.entries.length > 0}
|
|
251
|
+
<!-- The triage filters on two independent axes, dressed to the footer grammar. The publish-state
|
|
252
|
+
partition is one bordered segmented control: the shared border carries the pick-one semantics
|
|
253
|
+
and the active segment tints with a check (the non-color cue, WCAG 1.4.1). The Hidden toggle
|
|
254
|
+
is a separate standalone check-and-tint toggle that composes with the active partition. Each
|
|
255
|
+
count dims to muted when zero, so a sparse list never jumps. -->
|
|
256
|
+
<div class="mb-4 flex flex-wrap items-center gap-3">
|
|
257
|
+
<div role="group" aria-label="Filter by publish state" class="bg-base-100 inline-flex items-center overflow-hidden rounded-lg border border-[var(--cairn-card-border)]">
|
|
258
|
+
{#each segments as seg, i (seg.value)}
|
|
259
|
+
<button type="button" class="{segButtonClass(partition === seg.value)} {i > 0 ? 'border-l border-[var(--cairn-card-border)]' : ''}" aria-pressed={partition === seg.value} onclick={() => setPartition(seg.value)}>
|
|
260
|
+
{#if partition === seg.value}{@render check()}{/if}
|
|
261
|
+
{seg.label}<span class="tabular-nums">{seg.count()}</span>
|
|
262
|
+
</button>
|
|
263
|
+
{/each}
|
|
173
264
|
</div>
|
|
174
|
-
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
<
|
|
265
|
+
<span class="h-5 w-px bg-[var(--cairn-card-border)]" aria-hidden="true"></span>
|
|
266
|
+
<button type="button" class={hiddenToggleClass(hiddenOnly)} aria-pressed={hiddenOnly} onclick={toggleHidden}>
|
|
267
|
+
{#if hiddenOnly}{@render check()}{/if}
|
|
268
|
+
Hidden<span class="tabular-nums">{counts.hidden}</span>
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
{/if}
|
|
272
|
+
|
|
273
|
+
{#if data.entries.length === 0}
|
|
274
|
+
<!-- The empty state owns the content area (no card): the cairn mark, concept-named copy, and the
|
|
275
|
+
create CTA centered on a tall fill, so a first-run office reads as composed. -->
|
|
276
|
+
<div class="flex min-h-[56vh] flex-col items-center justify-center gap-4 px-6 py-16 text-center">
|
|
277
|
+
<CairnLogo class="h-12 w-12 text-primary opacity-30" />
|
|
278
|
+
<div class="space-y-1">
|
|
279
|
+
<p class="font-semibold text-base-content">No {data.label.toLowerCase()} yet</p>
|
|
280
|
+
<p class="text-sm text-[var(--color-muted)]">Stack your first one and it will show up here.</p>
|
|
178
281
|
</div>
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
{
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
282
|
+
<button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
|
|
283
|
+
<PlusIcon class="h-4 w-4" /> New {data.label}
|
|
284
|
+
</button>
|
|
285
|
+
</div>
|
|
286
|
+
{:else}
|
|
287
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 mb-4 overflow-x-auto shadow-[var(--cairn-shadow)]">
|
|
288
|
+
{#if sorted.length === 0}
|
|
289
|
+
<!-- A filter or a search narrowed the list to zero; the entries exist, none match. Offer the
|
|
290
|
+
way back: a search query clears, a filter is named in the copy. -->
|
|
291
|
+
<div role="status" class="flex flex-col items-center gap-3 px-6 py-14 text-center">
|
|
292
|
+
<SearchIcon class="h-8 w-8 text-[var(--color-subtle)] opacity-40" aria-hidden="true" />
|
|
293
|
+
{#if query.trim()}
|
|
294
|
+
<p class="text-sm text-[var(--color-muted)]">No {data.label.toLowerCase()} match <span class="font-medium text-base-content">"{query}"</span>.</p>
|
|
295
|
+
<button type="button" class="text-[0.8125rem] font-medium text-primary underline [text-underline-offset:2px]" onclick={clearSearch}>Clear search</button>
|
|
296
|
+
{:else}
|
|
297
|
+
<p class="text-sm text-[var(--color-muted)]">No {data.label.toLowerCase()} match this filter.</p>
|
|
298
|
+
{/if}
|
|
299
|
+
</div>
|
|
300
|
+
{:else}
|
|
301
|
+
<table class="table">
|
|
302
|
+
<thead>
|
|
303
|
+
<tr class="border-base-300">
|
|
304
|
+
<th aria-sort={sortKey === 'title' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
|
|
305
|
+
<button type="button" class={sortButton} aria-label="Sort by title" onclick={() => toggleSort('title')}>
|
|
306
|
+
Title
|
|
307
|
+
{#if sortKey === 'title'}
|
|
196
308
|
{#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
|
|
197
309
|
{:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
|
|
198
310
|
</button>
|
|
199
311
|
</th>
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
{
|
|
210
|
-
<
|
|
211
|
-
|
|
312
|
+
{#if data.dated}
|
|
313
|
+
<th class="hidden w-28 sm:table-cell" aria-sort={sortKey === 'date' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
|
|
314
|
+
<button type="button" class={sortButton} aria-label="Sort by date" onclick={() => toggleSort('date')}>
|
|
315
|
+
Date
|
|
316
|
+
{#if sortKey === 'date'}
|
|
317
|
+
{#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
|
|
318
|
+
{:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
|
|
319
|
+
</button>
|
|
320
|
+
</th>
|
|
321
|
+
{/if}
|
|
322
|
+
<th class="{headerLabel} w-28">Status</th>
|
|
323
|
+
<th class="w-12 text-right"><span class="sr-only">Actions</span></th>
|
|
324
|
+
</tr>
|
|
325
|
+
</thead>
|
|
326
|
+
<tbody>
|
|
327
|
+
{#each pageRows as entry (entry.id)}
|
|
328
|
+
<tr class="transition-colors hover:bg-base-200/60">
|
|
329
|
+
<td class="max-w-0">
|
|
330
|
+
<a class="block truncate font-semibold hover:text-primary hover:underline {entry.draft ? draftDim : ''}" href={`/admin/${data.conceptId}/${entry.id}`}>{entry.title}</a>
|
|
331
|
+
{#if entry.draft}
|
|
332
|
+
<!-- Hidden is a row treatment, not a status badge: the row de-emphasizes and an
|
|
333
|
+
eye-off tag sits by the title, leaving the Status cell to its publish badge. -->
|
|
334
|
+
<span class="mt-0.5 inline-flex items-center gap-1 text-[0.6875rem] font-semibold uppercase tracking-[0.02em] text-[var(--color-muted)]">
|
|
335
|
+
<EyeOffIcon class="h-3 w-3" aria-hidden="true" />Hidden
|
|
336
|
+
</span>
|
|
337
|
+
{/if}
|
|
338
|
+
{#if entry.summary}
|
|
339
|
+
<div data-summary class="mt-0.5 truncate text-[0.8125rem] text-[var(--color-muted)]">{entry.summary}</div>
|
|
340
|
+
{/if}
|
|
341
|
+
</td>
|
|
342
|
+
{#if data.dated}<td class="hidden w-28 text-sm tabular-nums text-[var(--color-muted)] sm:table-cell">{formatDate(entry.date)}</td>{/if}
|
|
343
|
+
<td class="w-28">
|
|
212
344
|
{#if entry.status === 'new'}<span class="badge badge-info badge-sm font-medium">New</span>
|
|
213
|
-
{:else if entry.status === 'edited'}<span class="badge badge-
|
|
345
|
+
{:else if entry.status === 'edited'}<span class="badge badge-sm border-transparent bg-primary/10 font-medium text-primary">Edited</span>
|
|
214
346
|
{:else}<span class="badge badge-ghost badge-sm font-medium">Published</span>{/if}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
347
|
+
</td>
|
|
348
|
+
<td class="w-12 text-right">
|
|
349
|
+
{#if deleteRefused?.id === entry.id}
|
|
350
|
+
<!-- A prior delete was refused: DeleteDialog names the blockers and offers no confirm. -->
|
|
351
|
+
<DeleteDialog conceptId={data.conceptId} id={entry.id} label={data.label} inboundLinks={deleteRefused.inboundLinks} pending={entry.status !== 'published'} />
|
|
352
|
+
{:else}
|
|
353
|
+
<form method="POST" action="?/delete">
|
|
354
|
+
<CsrfField />
|
|
355
|
+
<input type="hidden" name="id" value={entry.id} />
|
|
356
|
+
<button type="submit" class="btn btn-ghost btn-sm" aria-label="Delete {entry.title}">
|
|
357
|
+
<Trash2Icon class="h-4 w-4 text-error" />
|
|
358
|
+
</button>
|
|
359
|
+
</form>
|
|
360
|
+
{/if}
|
|
361
|
+
</td>
|
|
362
|
+
</tr>
|
|
363
|
+
{/each}
|
|
364
|
+
</tbody>
|
|
365
|
+
</table>
|
|
366
|
+
<!-- The create affordance baked into the list body: a full-width borderless foot row so a
|
|
367
|
+
short list always shows its next step rather than just stopping. Same action as the
|
|
368
|
+
header New button. -->
|
|
369
|
+
<button type="button" class="flex w-full items-center gap-2 border-t border-[var(--cairn-card-border)] px-6 py-3 text-sm font-medium text-primary hover:bg-primary/[0.06]" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
|
|
370
|
+
<PlusIcon class="h-4 w-4" /> New {data.label}
|
|
371
|
+
</button>
|
|
372
|
+
{/if}
|
|
373
|
+
</div>
|
|
374
|
+
{/if}
|
|
238
375
|
|
|
239
376
|
{#if data.entries.length > 0}
|
|
240
377
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
@@ -8,10 +8,12 @@ interface Props {
|
|
|
8
8
|
form?: Partial<DeleteRefusal> | null;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* One concept's list view
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* One concept's list view, dressed to the office gold standard. A triage bar partitions by publish
|
|
12
|
+
* state (a bordered segmented control with live counts) beside an orthogonal Hidden toggle. The list
|
|
13
|
+
* is an enriched sortable table: each row carries a title with a muted summary sub-line, the date, the
|
|
14
|
+
* publish-state badge, and a delete action. A draft row de-emphasizes and carries an eye-off Hidden
|
|
15
|
+
* tag by the title. A trailing New row at the foot of the card opens the same create dialog as the
|
|
16
|
+
* header button. Filtering, sorting, and paging run over the loaded entries in component state.
|
|
15
17
|
*/
|
|
16
18
|
declare const ConceptList: import("svelte").Component<Props, {}, "">;
|
|
17
19
|
type ConceptList = ReturnType<typeof ConceptList>;
|
|
@@ -3440,6 +3440,10 @@
|
|
|
3440
3440
|
}
|
|
3441
3441
|
}
|
|
3442
3442
|
|
|
3443
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-0\.5 {
|
|
3444
|
+
margin-top: calc(var(--spacing) * .5);
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3443
3447
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-1 {
|
|
3444
3448
|
margin-top: calc(var(--spacing) * 1);
|
|
3445
3449
|
}
|
|
@@ -3985,6 +3989,10 @@
|
|
|
3985
3989
|
min-height: 50vh;
|
|
3986
3990
|
}
|
|
3987
3991
|
|
|
3992
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .min-h-\[56vh\] {
|
|
3993
|
+
min-height: 56vh;
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3988
3996
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .min-h-full {
|
|
3989
3997
|
min-height: 100%;
|
|
3990
3998
|
}
|
|
@@ -4051,6 +4059,10 @@
|
|
|
4051
4059
|
width: calc(var(--spacing) * 12);
|
|
4052
4060
|
}
|
|
4053
4061
|
|
|
4062
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-28 {
|
|
4063
|
+
width: calc(var(--spacing) * 28);
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4054
4066
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-44 {
|
|
4055
4067
|
width: calc(var(--spacing) * 44);
|
|
4056
4068
|
}
|
|
@@ -4083,6 +4095,10 @@
|
|
|
4083
4095
|
width: 1px;
|
|
4084
4096
|
}
|
|
4085
4097
|
|
|
4098
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-0 {
|
|
4099
|
+
max-width: calc(var(--spacing) * 0);
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4086
4102
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-3xl {
|
|
4087
4103
|
max-width: var(--container-3xl);
|
|
4088
4104
|
}
|
|
@@ -4481,6 +4497,10 @@
|
|
|
4481
4497
|
border-color: var(--color-base-300);
|
|
4482
4498
|
}
|
|
4483
4499
|
|
|
4500
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-transparent {
|
|
4501
|
+
border-color: #0000;
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4484
4504
|
@layer daisyui.l1.l2 {
|
|
4485
4505
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) :where(:not(ul, details, .menu-title, .btn)).menu-active {
|
|
4486
4506
|
--tw-outline-style: none;
|
|
@@ -4712,6 +4732,10 @@
|
|
|
4712
4732
|
padding-block: calc(var(--spacing) * 2);
|
|
4713
4733
|
}
|
|
4714
4734
|
|
|
4735
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-3 {
|
|
4736
|
+
padding-block: calc(var(--spacing) * 3);
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4715
4739
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-3\.5 {
|
|
4716
4740
|
padding-block: calc(var(--spacing) * 3.5);
|
|
4717
4741
|
}
|
|
@@ -4886,6 +4910,11 @@
|
|
|
4886
4910
|
letter-spacing: -.01em;
|
|
4887
4911
|
}
|
|
4888
4912
|
|
|
4913
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .tracking-\[0\.02em\] {
|
|
4914
|
+
--tw-tracking: .02em;
|
|
4915
|
+
letter-spacing: .02em;
|
|
4916
|
+
}
|
|
4917
|
+
|
|
4889
4918
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .tracking-\[0\.08em\] {
|
|
4890
4919
|
--tw-tracking: .08em;
|
|
4891
4920
|
letter-spacing: .08em;
|
|
@@ -4987,6 +5016,11 @@
|
|
|
4987
5016
|
font-variant-numeric: var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, );
|
|
4988
5017
|
}
|
|
4989
5018
|
|
|
5019
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .tabular-nums {
|
|
5020
|
+
--tw-numeric-spacing: tabular-nums;
|
|
5021
|
+
font-variant-numeric: var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, );
|
|
5022
|
+
}
|
|
5023
|
+
|
|
4990
5024
|
@layer daisyui.l1.l2 {
|
|
4991
5025
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .link-hover {
|
|
4992
5026
|
text-decoration-line: none;
|
|
@@ -5053,6 +5087,10 @@
|
|
|
5053
5087
|
opacity: .7;
|
|
5054
5088
|
}
|
|
5055
5089
|
|
|
5090
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .opacity-\[0\.62\] {
|
|
5091
|
+
opacity: .62;
|
|
5092
|
+
}
|
|
5093
|
+
|
|
5056
5094
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .shadow {
|
|
5057
5095
|
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a);
|
|
5058
5096
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
|
@@ -5306,6 +5344,18 @@
|
|
|
5306
5344
|
}
|
|
5307
5345
|
}
|
|
5308
5346
|
|
|
5347
|
+
@media (hover: hover) {
|
|
5348
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:bg-primary\/\[0\.06\]:hover {
|
|
5349
|
+
background-color: var(--color-primary);
|
|
5350
|
+
}
|
|
5351
|
+
|
|
5352
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
5353
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:bg-primary\/\[0\.06\]:hover {
|
|
5354
|
+
background-color: color-mix(in oklab, var(--color-primary) 6%, transparent);
|
|
5355
|
+
}
|
|
5356
|
+
}
|
|
5357
|
+
}
|
|
5358
|
+
|
|
5309
5359
|
@media (hover: hover) {
|
|
5310
5360
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:text-\[var\(--color-primary\)\]:hover {
|
|
5311
5361
|
color: var(--color-primary);
|
package/dist/content/manifest.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// it; the save path patches one entry and commits it with the content in one commit. Each entry
|
|
5
5
|
// carries its identity and its outbound cairn: edges, so the manifest is the link graph.
|
|
6
6
|
import { parseMarkdown } from './frontmatter.js';
|
|
7
|
+
import { deriveExcerpt } from './excerpt.js';
|
|
7
8
|
import { entryIdentity, asString } from './identity.js';
|
|
8
9
|
import { extractCairnLinks } from './links.js';
|
|
9
10
|
/** Build one manifest entry from a content file. Drafts are included and flagged. The id, date, and
|
|
@@ -18,6 +19,9 @@ export function manifestEntryFromFile(descriptor, file) {
|
|
|
18
19
|
title: asString(frontmatter.title) ?? id,
|
|
19
20
|
date,
|
|
20
21
|
permalink,
|
|
22
|
+
// Coalesce an empty excerpt to undefined, so an empty-body entry carries no summary key at all
|
|
23
|
+
// (matching serialize's optional-spread) and the in-memory and serialized shapes agree.
|
|
24
|
+
summary: deriveExcerpt(body, { description: asString(frontmatter.description) }) || undefined,
|
|
21
25
|
draft: frontmatter.draft === true,
|
|
22
26
|
links: extractCairnLinks(body),
|
|
23
27
|
};
|
|
@@ -38,6 +42,7 @@ export function serializeManifest(manifest) {
|
|
|
38
42
|
title: e.title,
|
|
39
43
|
...(e.date ? { date: e.date } : {}),
|
|
40
44
|
permalink: e.permalink,
|
|
45
|
+
...(e.summary ? { summary: e.summary } : {}),
|
|
41
46
|
draft: e.draft,
|
|
42
47
|
links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
|
|
43
48
|
}));
|
|
@@ -68,6 +73,7 @@ export function parseManifest(raw) {
|
|
|
68
73
|
typeof e.permalink === 'string' &&
|
|
69
74
|
typeof e.draft === 'boolean' &&
|
|
70
75
|
(e.date === undefined || typeof e.date === 'string') &&
|
|
76
|
+
(e.summary === undefined || typeof e.summary === 'string') &&
|
|
71
77
|
Array.isArray(e.links);
|
|
72
78
|
if (!ok) {
|
|
73
79
|
throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// every operation reads the descriptor and its routing rule, never a hardcoded concept id.
|
|
5
5
|
import { parseMarkdown } from '../content/frontmatter.js';
|
|
6
6
|
import { entryId, entryIdentity, asDate, asString, asTags } from '../content/identity.js';
|
|
7
|
-
import { deriveExcerpt, wordCount } from '
|
|
7
|
+
import { deriveExcerpt, wordCount } from '../content/excerpt.js';
|
|
8
8
|
/** Map a Vite eager `?raw` glob record (`{ path: raw }`) to `RawFile[]`. */
|
|
9
9
|
export function fromGlob(record) {
|
|
10
10
|
return Object.entries(record).map(([path, raw]) => ({ path, raw }));
|
package/dist/delivery/data.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type { SiteResolver, ConceptIndex } from './site-resolver.js';
|
|
|
5
5
|
export { createSiteIndexes } from './site-indexes.js';
|
|
6
6
|
export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
|
|
7
7
|
export { siteDescriptors } from './site-descriptors.js';
|
|
8
|
-
export { deriveExcerpt, wordCount } from '
|
|
8
|
+
export { deriveExcerpt, wordCount } from '../content/excerpt.js';
|
|
9
9
|
export { buildRssFeed, buildJsonFeed } from './feeds.js';
|
|
10
10
|
export type { FeedChannel, FeedItem } from './feeds.js';
|
|
11
11
|
export { buildSitemap } from './sitemap.js';
|
package/dist/delivery/data.js
CHANGED
|
@@ -5,7 +5,7 @@ export { createContentIndex, fromGlob } from './content-index.js';
|
|
|
5
5
|
export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
|
|
6
6
|
export { createSiteIndexes } from './site-indexes.js';
|
|
7
7
|
export { siteDescriptors } from './site-descriptors.js';
|
|
8
|
-
export { deriveExcerpt, wordCount } from '
|
|
8
|
+
export { deriveExcerpt, wordCount } from '../content/excerpt.js';
|
|
9
9
|
export { buildRssFeed, buildJsonFeed } from './feeds.js';
|
|
10
10
|
export { buildSitemap } from './sitemap.js';
|
|
11
11
|
export { buildRobots } from './robots.js';
|
|
@@ -44,6 +44,9 @@ export interface EntrySummary {
|
|
|
44
44
|
draft: boolean;
|
|
45
45
|
/** Publish state derived from the ref set: live as-is, live with pending edits, or branch-only. */
|
|
46
46
|
status: 'published' | 'edited' | 'new';
|
|
47
|
+
/** The row's one-line summary: the manifest's indexed excerpt for a published row, the branch
|
|
48
|
+
* frontmatter/body excerpt for a pending one, and null when neither yields text. */
|
|
49
|
+
summary: string | null;
|
|
47
50
|
}
|
|
48
51
|
/** The concept list view's data. */
|
|
49
52
|
export interface ListData {
|
|
@@ -6,6 +6,8 @@ import { redirect, error, fail } from '@sveltejs/kit';
|
|
|
6
6
|
import { findConcept } from '../content/concepts.js';
|
|
7
7
|
import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../content/links.js';
|
|
8
8
|
import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
|
|
9
|
+
import { deriveExcerpt } from '../content/excerpt.js';
|
|
10
|
+
import { asString } from '../content/identity.js';
|
|
9
11
|
import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
|
|
10
12
|
import { appCredentials } from '../github/credentials.js';
|
|
11
13
|
import { listMarkdown, readRaw, commitFiles } from '../github/repo.js';
|
|
@@ -108,14 +110,17 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
108
110
|
try {
|
|
109
111
|
const raw = await readRaw(repo, file.path, token);
|
|
110
112
|
if (raw === null)
|
|
111
|
-
return { id: file.id, title: file.id, date: null, draft: false, status };
|
|
112
|
-
const { frontmatter } = parseMarkdown(raw);
|
|
113
|
+
return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
|
|
114
|
+
const { frontmatter, body } = parseMarkdown(raw);
|
|
113
115
|
const title = typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title : file.id;
|
|
114
116
|
const date = dateInputValue(frontmatter.date) || null;
|
|
115
|
-
|
|
117
|
+
// Normalize an empty excerpt to null, so a pending row matches EntrySummary's `string | null`
|
|
118
|
+
// contract (the published builder already coalesces with `?? null`).
|
|
119
|
+
const summary = deriveExcerpt(body, { description: asString(frontmatter.description) }) || null;
|
|
120
|
+
return { id: file.id, title, date, draft: frontmatter.draft === true, status, summary };
|
|
116
121
|
}
|
|
117
122
|
catch {
|
|
118
|
-
return { id: file.id, title: file.id, date: null, draft: false, status };
|
|
123
|
+
return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
|
|
119
124
|
}
|
|
120
125
|
}
|
|
121
126
|
/** Read an entry's list row from its pending branch, so a pending title or draft change shows
|
|
@@ -177,7 +182,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
177
182
|
.sort((a, b) => b.id.localeCompare(a.id));
|
|
178
183
|
const entries = await Promise.all(rows.map((e) => pendingIds.has(e.id)
|
|
179
184
|
? pendingRow(concept, e.id, 'edited', token)
|
|
180
|
-
: { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' }));
|
|
185
|
+
: { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published', summary: e.summary ?? null }));
|
|
181
186
|
const listed = new Set(rows.map((e) => e.id));
|
|
182
187
|
const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
|
|
183
188
|
return { ...base, entries: [...entries, ...newRows], error: null };
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
One concept's list view
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
One concept's list view, dressed to the office gold standard. A triage bar partitions by publish
|
|
4
|
+
state (a bordered segmented control with live counts) beside an orthogonal Hidden toggle. The list
|
|
5
|
+
is an enriched sortable table: each row carries a title with a muted summary sub-line, the date, the
|
|
6
|
+
publish-state badge, and a delete action. A draft row de-emphasizes and carries an eye-off Hidden
|
|
7
|
+
tag by the title. A trailing New row at the foot of the card opens the same create dialog as the
|
|
8
|
+
header button. Filtering, sorting, and paging run over the loaded entries in component state.
|
|
7
9
|
-->
|
|
8
10
|
<script lang="ts">
|
|
9
11
|
import { slugify } from '../content/ids.js';
|
|
@@ -12,6 +14,7 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
12
14
|
import DeleteDialog from './DeleteDialog.svelte';
|
|
13
15
|
import CairnLogo from './CairnLogo.svelte';
|
|
14
16
|
import { SearchIcon, ArrowUpIcon, ArrowDownIcon, ChevronsUpDownIcon, ChevronLeftIcon, ChevronRightIcon, PlusIcon, Trash2Icon } from './admin-icons.js';
|
|
17
|
+
import EyeOffIcon from '@lucide/svelte/icons/eye-off';
|
|
15
18
|
|
|
16
19
|
interface Props {
|
|
17
20
|
/** The list load's data: the concept, its entries, and any inline or form errors. */
|
|
@@ -31,7 +34,14 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
31
34
|
);
|
|
32
35
|
|
|
33
36
|
type SortKey = 'title' | 'date';
|
|
37
|
+
// The triage runs on two independent axes. A pick-one publish-state partition (`all` passes
|
|
38
|
+
// everything, `pending` is new + edited, `published` is live-as-is) and a separate Hidden
|
|
39
|
+
// toggle that composes with it. They are orthogonal: a published-but-hidden entry is on main
|
|
40
|
+
// (so it counts as Published) and also hidden from the public site (so it counts as Hidden).
|
|
41
|
+
type Partition = 'all' | 'pending' | 'published';
|
|
34
42
|
let query = $state('');
|
|
43
|
+
let partition = $state<Partition>('all');
|
|
44
|
+
let hiddenOnly = $state(false);
|
|
35
45
|
let sortKey = $state<SortKey>('date');
|
|
36
46
|
// Newest first by default: a dated concept reads most-recent-on-top, the usual CMS convention.
|
|
37
47
|
let sortAsc = $state(false);
|
|
@@ -45,10 +55,82 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
45
55
|
return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed);
|
|
46
56
|
}
|
|
47
57
|
|
|
58
|
+
// Triage counts over the full loaded set, each axis counted independently. Pending is new +
|
|
59
|
+
// edited (status !== 'published'); Published is live-as-is (status === 'published'); Hidden is
|
|
60
|
+
// the draft rows. The axes overlap: a published-but-hidden entry counts in BOTH Published and
|
|
61
|
+
// Hidden. All is the unconditional total.
|
|
62
|
+
const counts = $derived({
|
|
63
|
+
all: data.entries.length,
|
|
64
|
+
pending: data.entries.filter((e) => e.status !== 'published').length,
|
|
65
|
+
published: data.entries.filter((e) => e.status === 'published').length,
|
|
66
|
+
hidden: data.entries.filter((e) => e.draft).length,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The three publish-state segments, in display order. Each names its partition value, its label,
|
|
70
|
+
// and the count axis it shows; the markup loops this so the segments share one block.
|
|
71
|
+
const segments: { value: Partition; label: string; count: () => number }[] = [
|
|
72
|
+
{ value: 'all', label: 'All', count: () => counts.all },
|
|
73
|
+
{ value: 'pending', label: 'Pending edits', count: () => counts.pending },
|
|
74
|
+
{ value: 'published', label: 'Published', count: () => counts.published },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
function matchesPartition(entry: EntrySummary): boolean {
|
|
78
|
+
switch (partition) {
|
|
79
|
+
case 'pending':
|
|
80
|
+
return entry.status !== 'published';
|
|
81
|
+
case 'published':
|
|
82
|
+
return entry.status === 'published';
|
|
83
|
+
default:
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Compose the partition and the Hidden axis with the search query; sort and paging run
|
|
89
|
+
// downstream. Hidden is orthogonal: when on, it narrows the partition to its draft rows.
|
|
48
90
|
const filtered = $derived(
|
|
49
|
-
data.entries.filter(
|
|
91
|
+
data.entries.filter(
|
|
92
|
+
(e) =>
|
|
93
|
+
matchesPartition(e) &&
|
|
94
|
+
(!hiddenOnly || e.draft === true) &&
|
|
95
|
+
e.title.toLowerCase().includes(query.trim().toLowerCase()),
|
|
96
|
+
),
|
|
50
97
|
);
|
|
51
98
|
|
|
99
|
+
function setPartition(next: Partition) {
|
|
100
|
+
partition = next;
|
|
101
|
+
page = 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toggleHidden() {
|
|
105
|
+
hiddenOnly = !hiddenOnly;
|
|
106
|
+
page = 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function clearSearch() {
|
|
110
|
+
query = '';
|
|
111
|
+
page = 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The triage controls dress to the established footer grammar (the design system's segmented /
|
|
115
|
+
// check-and-tint recipe). Each helper returns a verbatim Tailwind string so the admin CSS
|
|
116
|
+
// build's @source scan reads the utilities whole. The scoped button reset (cairn-admin.css)
|
|
117
|
+
// already strips UA chrome from these bare buttons.
|
|
118
|
+
//
|
|
119
|
+
// A segment of the bordered publish-state control: the shared group border carries the pick-one
|
|
120
|
+
// semantics, so a segment stays borderless; the active one tints and bolds.
|
|
121
|
+
function segButtonClass(pressed: boolean): string {
|
|
122
|
+
return `inline-flex items-center gap-1.5 px-3 py-1 text-[0.8125rem] font-normal ${pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
|
|
123
|
+
}
|
|
124
|
+
// The standalone Hidden toggle: rounded, transparent until hover, check-and-tint when pressed.
|
|
125
|
+
function hiddenToggleClass(pressed: boolean): string {
|
|
126
|
+
return `inline-flex items-center gap-1.5 rounded-lg px-3 py-1 text-[0.8125rem] font-normal hover:bg-base-content/[0.06] ${pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
|
|
127
|
+
}
|
|
128
|
+
// Hidden is a row treatment: a draft row de-emphasizes its TITLE by opacity (the title is
|
|
129
|
+
// high-contrast base-content, so it stays above the AA text floor when dimmed). The already-muted
|
|
130
|
+
// summary line is left at full strength: stacking opacity on muted text drops it below 4.5:1 in
|
|
131
|
+
// the light theme, so the dimmed title plus the eye-off tag carry the "hidden" read instead.
|
|
132
|
+
const draftDim = 'opacity-[0.62]';
|
|
133
|
+
|
|
52
134
|
// Sort key for one entry: the lowercased title, or the ISO date string (lexical order is
|
|
53
135
|
// chronological). A null date sorts as the empty string.
|
|
54
136
|
function sortValue(entry: EntrySummary): string {
|
|
@@ -116,6 +198,12 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
116
198
|
);
|
|
117
199
|
</script>
|
|
118
200
|
|
|
201
|
+
<!-- The non-color selected cue for the triage controls (WCAG 1.4.1): a small check glyph that
|
|
202
|
+
renders only inside the active segment or toggle, so hue never carries the state alone. -->
|
|
203
|
+
{#snippet check()}
|
|
204
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
|
205
|
+
{/snippet}
|
|
206
|
+
|
|
119
207
|
<header class="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
|
120
208
|
<h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.label}</h1>
|
|
121
209
|
<div class="flex items-center gap-3 sm:flex-1 sm:flex-wrap sm:justify-end">
|
|
@@ -159,82 +247,131 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
159
247
|
</div>
|
|
160
248
|
{/if}
|
|
161
249
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
<
|
|
172
|
-
|
|
250
|
+
{#if data.entries.length > 0}
|
|
251
|
+
<!-- The triage filters on two independent axes, dressed to the footer grammar. The publish-state
|
|
252
|
+
partition is one bordered segmented control: the shared border carries the pick-one semantics
|
|
253
|
+
and the active segment tints with a check (the non-color cue, WCAG 1.4.1). The Hidden toggle
|
|
254
|
+
is a separate standalone check-and-tint toggle that composes with the active partition. Each
|
|
255
|
+
count dims to muted when zero, so a sparse list never jumps. -->
|
|
256
|
+
<div class="mb-4 flex flex-wrap items-center gap-3">
|
|
257
|
+
<div role="group" aria-label="Filter by publish state" class="bg-base-100 inline-flex items-center overflow-hidden rounded-lg border border-[var(--cairn-card-border)]">
|
|
258
|
+
{#each segments as seg, i (seg.value)}
|
|
259
|
+
<button type="button" class="{segButtonClass(partition === seg.value)} {i > 0 ? 'border-l border-[var(--cairn-card-border)]' : ''}" aria-pressed={partition === seg.value} onclick={() => setPartition(seg.value)}>
|
|
260
|
+
{#if partition === seg.value}{@render check()}{/if}
|
|
261
|
+
{seg.label}<span class="tabular-nums">{seg.count()}</span>
|
|
262
|
+
</button>
|
|
263
|
+
{/each}
|
|
173
264
|
</div>
|
|
174
|
-
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
<
|
|
265
|
+
<span class="h-5 w-px bg-[var(--cairn-card-border)]" aria-hidden="true"></span>
|
|
266
|
+
<button type="button" class={hiddenToggleClass(hiddenOnly)} aria-pressed={hiddenOnly} onclick={toggleHidden}>
|
|
267
|
+
{#if hiddenOnly}{@render check()}{/if}
|
|
268
|
+
Hidden<span class="tabular-nums">{counts.hidden}</span>
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
{/if}
|
|
272
|
+
|
|
273
|
+
{#if data.entries.length === 0}
|
|
274
|
+
<!-- The empty state owns the content area (no card): the cairn mark, concept-named copy, and the
|
|
275
|
+
create CTA centered on a tall fill, so a first-run office reads as composed. -->
|
|
276
|
+
<div class="flex min-h-[56vh] flex-col items-center justify-center gap-4 px-6 py-16 text-center">
|
|
277
|
+
<CairnLogo class="h-12 w-12 text-primary opacity-30" />
|
|
278
|
+
<div class="space-y-1">
|
|
279
|
+
<p class="font-semibold text-base-content">No {data.label.toLowerCase()} yet</p>
|
|
280
|
+
<p class="text-sm text-[var(--color-muted)]">Stack your first one and it will show up here.</p>
|
|
178
281
|
</div>
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
{
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
282
|
+
<button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
|
|
283
|
+
<PlusIcon class="h-4 w-4" /> New {data.label}
|
|
284
|
+
</button>
|
|
285
|
+
</div>
|
|
286
|
+
{:else}
|
|
287
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 mb-4 overflow-x-auto shadow-[var(--cairn-shadow)]">
|
|
288
|
+
{#if sorted.length === 0}
|
|
289
|
+
<!-- A filter or a search narrowed the list to zero; the entries exist, none match. Offer the
|
|
290
|
+
way back: a search query clears, a filter is named in the copy. -->
|
|
291
|
+
<div role="status" class="flex flex-col items-center gap-3 px-6 py-14 text-center">
|
|
292
|
+
<SearchIcon class="h-8 w-8 text-[var(--color-subtle)] opacity-40" aria-hidden="true" />
|
|
293
|
+
{#if query.trim()}
|
|
294
|
+
<p class="text-sm text-[var(--color-muted)]">No {data.label.toLowerCase()} match <span class="font-medium text-base-content">"{query}"</span>.</p>
|
|
295
|
+
<button type="button" class="text-[0.8125rem] font-medium text-primary underline [text-underline-offset:2px]" onclick={clearSearch}>Clear search</button>
|
|
296
|
+
{:else}
|
|
297
|
+
<p class="text-sm text-[var(--color-muted)]">No {data.label.toLowerCase()} match this filter.</p>
|
|
298
|
+
{/if}
|
|
299
|
+
</div>
|
|
300
|
+
{:else}
|
|
301
|
+
<table class="table">
|
|
302
|
+
<thead>
|
|
303
|
+
<tr class="border-base-300">
|
|
304
|
+
<th aria-sort={sortKey === 'title' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
|
|
305
|
+
<button type="button" class={sortButton} aria-label="Sort by title" onclick={() => toggleSort('title')}>
|
|
306
|
+
Title
|
|
307
|
+
{#if sortKey === 'title'}
|
|
196
308
|
{#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
|
|
197
309
|
{:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
|
|
198
310
|
</button>
|
|
199
311
|
</th>
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
{
|
|
210
|
-
<
|
|
211
|
-
|
|
312
|
+
{#if data.dated}
|
|
313
|
+
<th class="hidden w-28 sm:table-cell" aria-sort={sortKey === 'date' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
|
|
314
|
+
<button type="button" class={sortButton} aria-label="Sort by date" onclick={() => toggleSort('date')}>
|
|
315
|
+
Date
|
|
316
|
+
{#if sortKey === 'date'}
|
|
317
|
+
{#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
|
|
318
|
+
{:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
|
|
319
|
+
</button>
|
|
320
|
+
</th>
|
|
321
|
+
{/if}
|
|
322
|
+
<th class="{headerLabel} w-28">Status</th>
|
|
323
|
+
<th class="w-12 text-right"><span class="sr-only">Actions</span></th>
|
|
324
|
+
</tr>
|
|
325
|
+
</thead>
|
|
326
|
+
<tbody>
|
|
327
|
+
{#each pageRows as entry (entry.id)}
|
|
328
|
+
<tr class="transition-colors hover:bg-base-200/60">
|
|
329
|
+
<td class="max-w-0">
|
|
330
|
+
<a class="block truncate font-semibold hover:text-primary hover:underline {entry.draft ? draftDim : ''}" href={`/admin/${data.conceptId}/${entry.id}`}>{entry.title}</a>
|
|
331
|
+
{#if entry.draft}
|
|
332
|
+
<!-- Hidden is a row treatment, not a status badge: the row de-emphasizes and an
|
|
333
|
+
eye-off tag sits by the title, leaving the Status cell to its publish badge. -->
|
|
334
|
+
<span class="mt-0.5 inline-flex items-center gap-1 text-[0.6875rem] font-semibold uppercase tracking-[0.02em] text-[var(--color-muted)]">
|
|
335
|
+
<EyeOffIcon class="h-3 w-3" aria-hidden="true" />Hidden
|
|
336
|
+
</span>
|
|
337
|
+
{/if}
|
|
338
|
+
{#if entry.summary}
|
|
339
|
+
<div data-summary class="mt-0.5 truncate text-[0.8125rem] text-[var(--color-muted)]">{entry.summary}</div>
|
|
340
|
+
{/if}
|
|
341
|
+
</td>
|
|
342
|
+
{#if data.dated}<td class="hidden w-28 text-sm tabular-nums text-[var(--color-muted)] sm:table-cell">{formatDate(entry.date)}</td>{/if}
|
|
343
|
+
<td class="w-28">
|
|
212
344
|
{#if entry.status === 'new'}<span class="badge badge-info badge-sm font-medium">New</span>
|
|
213
|
-
{:else if entry.status === 'edited'}<span class="badge badge-
|
|
345
|
+
{:else if entry.status === 'edited'}<span class="badge badge-sm border-transparent bg-primary/10 font-medium text-primary">Edited</span>
|
|
214
346
|
{:else}<span class="badge badge-ghost badge-sm font-medium">Published</span>{/if}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
347
|
+
</td>
|
|
348
|
+
<td class="w-12 text-right">
|
|
349
|
+
{#if deleteRefused?.id === entry.id}
|
|
350
|
+
<!-- A prior delete was refused: DeleteDialog names the blockers and offers no confirm. -->
|
|
351
|
+
<DeleteDialog conceptId={data.conceptId} id={entry.id} label={data.label} inboundLinks={deleteRefused.inboundLinks} pending={entry.status !== 'published'} />
|
|
352
|
+
{:else}
|
|
353
|
+
<form method="POST" action="?/delete">
|
|
354
|
+
<CsrfField />
|
|
355
|
+
<input type="hidden" name="id" value={entry.id} />
|
|
356
|
+
<button type="submit" class="btn btn-ghost btn-sm" aria-label="Delete {entry.title}">
|
|
357
|
+
<Trash2Icon class="h-4 w-4 text-error" />
|
|
358
|
+
</button>
|
|
359
|
+
</form>
|
|
360
|
+
{/if}
|
|
361
|
+
</td>
|
|
362
|
+
</tr>
|
|
363
|
+
{/each}
|
|
364
|
+
</tbody>
|
|
365
|
+
</table>
|
|
366
|
+
<!-- The create affordance baked into the list body: a full-width borderless foot row so a
|
|
367
|
+
short list always shows its next step rather than just stopping. Same action as the
|
|
368
|
+
header New button. -->
|
|
369
|
+
<button type="button" class="flex w-full items-center gap-2 border-t border-[var(--cairn-card-border)] px-6 py-3 text-sm font-medium text-primary hover:bg-primary/[0.06]" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
|
|
370
|
+
<PlusIcon class="h-4 w-4" /> New {data.label}
|
|
371
|
+
</button>
|
|
372
|
+
{/if}
|
|
373
|
+
</div>
|
|
374
|
+
{/if}
|
|
238
375
|
|
|
239
376
|
{#if data.entries.length > 0}
|
|
240
377
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// it; the save path patches one entry and commits it with the content in one commit. Each entry
|
|
5
5
|
// carries its identity and its outbound cairn: edges, so the manifest is the link graph.
|
|
6
6
|
import { parseMarkdown } from './frontmatter.js';
|
|
7
|
+
import { deriveExcerpt } from './excerpt.js';
|
|
7
8
|
import { entryIdentity, asString } from './identity.js';
|
|
8
9
|
import { extractCairnLinks, type CairnRef, type LinkResolve } from './links.js';
|
|
9
10
|
import type { ConceptDescriptor } from './types.js';
|
|
@@ -15,6 +16,7 @@ export interface ManifestEntry {
|
|
|
15
16
|
title: string;
|
|
16
17
|
date?: string;
|
|
17
18
|
permalink: string;
|
|
19
|
+
summary?: string;
|
|
18
20
|
draft: boolean;
|
|
19
21
|
links: CairnRef[];
|
|
20
22
|
}
|
|
@@ -47,6 +49,9 @@ export function manifestEntryFromFile(descriptor: ConceptDescriptor, file: { pat
|
|
|
47
49
|
title: asString(frontmatter.title) ?? id,
|
|
48
50
|
date,
|
|
49
51
|
permalink,
|
|
52
|
+
// Coalesce an empty excerpt to undefined, so an empty-body entry carries no summary key at all
|
|
53
|
+
// (matching serialize's optional-spread) and the in-memory and serialized shapes agree.
|
|
54
|
+
summary: deriveExcerpt(body, { description: asString(frontmatter.description) }) || undefined,
|
|
50
55
|
draft: frontmatter.draft === true,
|
|
51
56
|
links: extractCairnLinks(body),
|
|
52
57
|
};
|
|
@@ -70,6 +75,7 @@ export function serializeManifest(manifest: Manifest): string {
|
|
|
70
75
|
title: e.title,
|
|
71
76
|
...(e.date ? { date: e.date } : {}),
|
|
72
77
|
permalink: e.permalink,
|
|
78
|
+
...(e.summary ? { summary: e.summary } : {}),
|
|
73
79
|
draft: e.draft,
|
|
74
80
|
links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
|
|
75
81
|
}));
|
|
@@ -102,6 +108,7 @@ export function parseManifest(raw: string): Manifest {
|
|
|
102
108
|
typeof e.permalink === 'string' &&
|
|
103
109
|
typeof e.draft === 'boolean' &&
|
|
104
110
|
(e.date === undefined || typeof e.date === 'string') &&
|
|
111
|
+
(e.summary === undefined || typeof e.summary === 'string') &&
|
|
105
112
|
Array.isArray(e.links);
|
|
106
113
|
if (!ok) {
|
|
107
114
|
throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// every operation reads the descriptor and its routing rule, never a hardcoded concept id.
|
|
5
5
|
import { parseMarkdown } from '../content/frontmatter.js';
|
|
6
6
|
import { entryId, entryIdentity, asDate, asString, asTags } from '../content/identity.js';
|
|
7
|
-
import { deriveExcerpt, wordCount } from '
|
|
7
|
+
import { deriveExcerpt, wordCount } from '../content/excerpt.js';
|
|
8
8
|
import type { ConceptDescriptor } from '../content/types.js';
|
|
9
9
|
|
|
10
10
|
/** A raw content file before parsing: the glob key and the file's full markdown text. */
|
package/src/lib/delivery/data.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type { SiteResolver, ConceptIndex } from './site-resolver.js';
|
|
|
8
8
|
export { createSiteIndexes } from './site-indexes.js';
|
|
9
9
|
export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
|
|
10
10
|
export { siteDescriptors } from './site-descriptors.js';
|
|
11
|
-
export { deriveExcerpt, wordCount } from '
|
|
11
|
+
export { deriveExcerpt, wordCount } from '../content/excerpt.js';
|
|
12
12
|
export { buildRssFeed, buildJsonFeed } from './feeds.js';
|
|
13
13
|
export type { FeedChannel, FeedItem } from './feeds.js';
|
|
14
14
|
export { buildSitemap } from './sitemap.js';
|
|
@@ -6,6 +6,8 @@ import { redirect, error, fail } from '@sveltejs/kit';
|
|
|
6
6
|
import { findConcept } from '../content/concepts.js';
|
|
7
7
|
import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../content/links.js';
|
|
8
8
|
import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
|
|
9
|
+
import { deriveExcerpt } from '../content/excerpt.js';
|
|
10
|
+
import { asString } from '../content/identity.js';
|
|
9
11
|
import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
|
|
10
12
|
import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
|
|
11
13
|
import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/repo.js';
|
|
@@ -56,6 +58,9 @@ export interface EntrySummary {
|
|
|
56
58
|
draft: boolean;
|
|
57
59
|
/** Publish state derived from the ref set: live as-is, live with pending edits, or branch-only. */
|
|
58
60
|
status: 'published' | 'edited' | 'new';
|
|
61
|
+
/** The row's one-line summary: the manifest's indexed excerpt for a published row, the branch
|
|
62
|
+
* frontmatter/body excerpt for a pending one, and null when neither yields text. */
|
|
63
|
+
summary: string | null;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
/** The concept list view's data. */
|
|
@@ -251,13 +256,16 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
251
256
|
): Promise<EntrySummary> {
|
|
252
257
|
try {
|
|
253
258
|
const raw = await readRaw(repo, file.path, token);
|
|
254
|
-
if (raw === null) return { id: file.id, title: file.id, date: null, draft: false, status };
|
|
255
|
-
const { frontmatter } = parseMarkdown(raw);
|
|
259
|
+
if (raw === null) return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
|
|
260
|
+
const { frontmatter, body } = parseMarkdown(raw);
|
|
256
261
|
const title = typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title : file.id;
|
|
257
262
|
const date = dateInputValue(frontmatter.date) || null;
|
|
258
|
-
|
|
263
|
+
// Normalize an empty excerpt to null, so a pending row matches EntrySummary's `string | null`
|
|
264
|
+
// contract (the published builder already coalesces with `?? null`).
|
|
265
|
+
const summary = deriveExcerpt(body, { description: asString(frontmatter.description) }) || null;
|
|
266
|
+
return { id: file.id, title, date, draft: frontmatter.draft === true, status, summary };
|
|
259
267
|
} catch {
|
|
260
|
-
return { id: file.id, title: file.id, date: null, draft: false, status };
|
|
268
|
+
return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
|
|
261
269
|
}
|
|
262
270
|
}
|
|
263
271
|
|
|
@@ -329,7 +337,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
329
337
|
rows.map((e) =>
|
|
330
338
|
pendingIds.has(e.id)
|
|
331
339
|
? pendingRow(concept, e.id, 'edited', token)
|
|
332
|
-
: { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' as const },
|
|
340
|
+
: { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' as const, summary: e.summary ?? null },
|
|
333
341
|
),
|
|
334
342
|
);
|
|
335
343
|
const listed = new Set(rows.map((e) => e.id));
|
|
File without changes
|
|
File without changes
|
|
File without changes
|