@vespercms/admin 0.0.0-stage → 0.1.0-next.1

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.
Files changed (207) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-_mkh0BSQ.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-4fVeej3c.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +35 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +16 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/screens/status.d.ts +36 -0
  89. package/dist/shortcuts-CarKeDqp.js +4666 -0
  90. package/dist/studio.css +3 -0
  91. package/dist/studio.js +6449 -0
  92. package/dist/types.d.ts +76 -0
  93. package/dist/widgets/basic.d.ts +25 -0
  94. package/dist/widgets/blank-date.d.ts +8 -0
  95. package/dist/widgets/crops.d.ts +43 -0
  96. package/dist/widgets/focal-point.d.ts +15 -0
  97. package/dist/widgets/geo-map.d.ts +10 -0
  98. package/dist/widgets/geo.d.ts +16 -0
  99. package/dist/widgets/group.d.ts +41 -0
  100. package/dist/widgets/icon.d.ts +9 -0
  101. package/dist/widgets/image.d.ts +16 -0
  102. package/dist/widgets/markdown-lazy.d.ts +2 -0
  103. package/dist/widgets/markdown.d.ts +2 -0
  104. package/dist/widgets/reference.d.ts +12 -0
  105. package/dist/widgets/registry.d.ts +32 -0
  106. package/dist/widgets/special.d.ts +20 -0
  107. package/dist/widgets/union.d.ts +36 -0
  108. package/package.json +63 -4
  109. package/src/a11y/FieldNotes.tsx +56 -0
  110. package/src/a11y/NotesDrawer.tsx +81 -0
  111. package/src/a11y/pdf.ts +45 -0
  112. package/src/a11y/run.ts +12 -0
  113. package/src/a11y/useNotes.ts +139 -0
  114. package/src/app/App.tsx +156 -0
  115. package/src/app/Screen.tsx +416 -0
  116. package/src/app/UserMenu.tsx +95 -0
  117. package/src/app/api.ts +176 -0
  118. package/src/app/appearance.ts +54 -0
  119. package/src/app/browser-api.ts +173 -0
  120. package/src/app/collection-icon.ts +15 -0
  121. package/src/app/context.tsx +32 -0
  122. package/src/app/entries-changed.ts +48 -0
  123. package/src/app/file-url.tsx +44 -0
  124. package/src/app/format.ts +39 -0
  125. package/src/app/idb.ts +82 -0
  126. package/src/app/load.ts +40 -0
  127. package/src/app/media.ts +20 -0
  128. package/src/app/navigation.ts +4 -0
  129. package/src/app/pane-context.ts +25 -0
  130. package/src/app/panes.tsx +513 -0
  131. package/src/app/router.ts +158 -0
  132. package/src/body/BodyEditor.tsx +1139 -0
  133. package/src/body/TableView.tsx +700 -0
  134. package/src/body/catalog.ts +198 -0
  135. package/src/body/checks.ts +83 -0
  136. package/src/body/chips.ts +150 -0
  137. package/src/body/commands.ts +191 -0
  138. package/src/body/document.ts +388 -0
  139. package/src/body/insert.tsx +301 -0
  140. package/src/body/nodes.ts +287 -0
  141. package/src/body/reveal.ts +93 -0
  142. package/src/body/scroll.ts +53 -0
  143. package/src/body/settings.tsx +510 -0
  144. package/src/body/shortcuts.ts +31 -0
  145. package/src/body/table.ts +192 -0
  146. package/src/body/ui.ts +77 -0
  147. package/src/body/views.tsx +268 -0
  148. package/src/build.ts +6 -0
  149. package/src/cloud/CloudGate.tsx +245 -0
  150. package/src/cloud/access.ts +52 -0
  151. package/src/cloud/changes.tsx +474 -0
  152. package/src/cloud/connection.ts +281 -0
  153. package/src/cloud/editor.tsx +250 -0
  154. package/src/cloud/session.ts +289 -0
  155. package/src/cloud/storage.ts +212 -0
  156. package/src/editor/Field.tsx +182 -0
  157. package/src/editor/PagePicker.tsx +102 -0
  158. package/src/editor/arrange.ts +98 -0
  159. package/src/editor/context.tsx +31 -0
  160. package/src/editor/copy.ts +30 -0
  161. package/src/editor/reveal.ts +38 -0
  162. package/src/editor/target.ts +104 -0
  163. package/src/editor/values.ts +71 -0
  164. package/src/github/GitHubGate.tsx +298 -0
  165. package/src/github/api.ts +81 -0
  166. package/src/github/cache.ts +64 -0
  167. package/src/github/session.ts +146 -0
  168. package/src/index.ts +10 -0
  169. package/src/media/MediaPicker.tsx +302 -0
  170. package/src/media/file-icon.ts +17 -0
  171. package/src/media/formats.ts +262 -0
  172. package/src/media/hygiene.ts +220 -0
  173. package/src/media/upload.ts +47 -0
  174. package/src/mount.tsx +20 -0
  175. package/src/schedule/ScheduleDialog.tsx +303 -0
  176. package/src/schedule/ScheduleSummary.tsx +59 -0
  177. package/src/schedule/ScheduledView.tsx +294 -0
  178. package/src/schedule/cloud.ts +66 -0
  179. package/src/schedule/now.ts +18 -0
  180. package/src/schedule/times.ts +122 -0
  181. package/src/screens/EntryEditor.tsx +1817 -0
  182. package/src/screens/EntryList.tsx +272 -0
  183. package/src/screens/Home.tsx +131 -0
  184. package/src/screens/ListPane.tsx +477 -0
  185. package/src/screens/MediaBrowser.tsx +253 -0
  186. package/src/screens/NotFound.tsx +19 -0
  187. package/src/screens/PageTree.tsx +738 -0
  188. package/src/screens/Redirects.tsx +522 -0
  189. package/src/screens/Unavailable.tsx +27 -0
  190. package/src/screens/status.tsx +104 -0
  191. package/src/studio.css +168 -0
  192. package/src/types.ts +82 -0
  193. package/src/widgets/basic.tsx +315 -0
  194. package/src/widgets/blank-date.ts +32 -0
  195. package/src/widgets/crops.tsx +399 -0
  196. package/src/widgets/focal-point.tsx +240 -0
  197. package/src/widgets/geo-map.tsx +160 -0
  198. package/src/widgets/geo.tsx +170 -0
  199. package/src/widgets/group.tsx +827 -0
  200. package/src/widgets/icon.tsx +47 -0
  201. package/src/widgets/image.tsx +407 -0
  202. package/src/widgets/markdown-lazy.tsx +22 -0
  203. package/src/widgets/markdown.tsx +267 -0
  204. package/src/widgets/reference.tsx +196 -0
  205. package/src/widgets/registry.ts +104 -0
  206. package/src/widgets/special.tsx +290 -0
  207. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,477 @@
1
+ import type { CollectionManifest, EntrySummary } from '@vespercms/core';
2
+ import {
3
+ ButtonLink,
4
+ Link,
5
+ Menu,
6
+ TextField,
7
+ Tree,
8
+ useDragAndDrop,
9
+ type TreeNode,
10
+ } from '@vespercms/ui';
11
+ import {
12
+ useCallback,
13
+ useEffect,
14
+ useLayoutEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ type KeyboardEvent,
19
+ } from 'react';
20
+ import type { SortDescriptor } from 'react-aria-components';
21
+ import type { StudioApi } from '../app/api.ts';
22
+ import { useStudio } from '../app/context.tsx';
23
+ import { entriesRewritten, onEntriesChanged } from '../app/entries-changed.ts';
24
+ import { formatRelative } from '../app/format.ts';
25
+ import { hasUnsavedChanges, hrefFor, navigate } from '../app/router.ts';
26
+ import { collectionIcon } from '../app/collection-icon.ts';
27
+ import { PaneFrame, useLeadHeading } from '../app/Screen.tsx';
28
+ import { entryShownTo, mayCreateIn } from '../cloud/access.ts';
29
+ import { useDrafts } from '../cloud/editor.tsx';
30
+ import { hasUpcoming, rowData, siteZone } from '../schedule/times.ts';
31
+ import { compareBy, defaultSort, formatListValue, labelFor, TITLE_KEYS } from './EntryList.tsx';
32
+ import {
33
+ StateWords,
34
+ entryState,
35
+ isNewInDraft,
36
+ kindText,
37
+ stateText,
38
+ type EntryState,
39
+ } from './status.tsx';
40
+
41
+ /*
42
+ * A flat collection's list as a pane (DECISIONS.md, "The studio is panes,
43
+ * full width"), with the list screen's tools: search, the status filter,
44
+ * sorting, the collection's list fields under each title, and New entry.
45
+ * The same pane stays beside the entries it opens. What was searched,
46
+ * filtered and sorted, and where it was scrolled, are kept here for the
47
+ * visit, so the list is as it was when it's drawn again.
48
+ *
49
+ * A collection with `orderBy` is listed in its order, and while it's shown
50
+ * that way, whole, its rows drag to a new place (or move with Alt+Up and
51
+ * Alt+Down, or the row's menu), which writes the order numbers
52
+ * (DECISIONS.md, "Flat collections are put in order by a number").
53
+ */
54
+
55
+ /** A row on the move, as its own type: a Markdown field would take plain text. */
56
+ const ENTRY_TYPE = 'application/x-vesper-entry';
57
+
58
+ type Filter = 'all' | 'drafts' | 'attention' | 'scheduled';
59
+ type Memory = { query: string; filter: Filter; sort: string | null; scrollTop: number };
60
+
61
+ // Kept per studio, so a fresh sign-in starts afresh.
62
+ const memories = new WeakMap<StudioApi, Map<string, Memory>>();
63
+ const lists = new WeakMap<StudioApi, Map<string, EntrySummary[]>>();
64
+
65
+ function memoryOf(api: StudioApi, collection: string): Memory {
66
+ let byCollection = memories.get(api);
67
+ if (!byCollection) {
68
+ byCollection = new Map();
69
+ memories.set(api, byCollection);
70
+ }
71
+ let memory = byCollection.get(collection);
72
+ if (!memory) {
73
+ memory = { query: '', filter: 'all', sort: null, scrollTop: 0 };
74
+ byCollection.set(collection, memory);
75
+ }
76
+ return memory;
77
+ }
78
+
79
+ function remember(api: StudioApi, collection: string, patch: Partial<Memory>): void {
80
+ Object.assign(memoryOf(api, collection), patch);
81
+ }
82
+
83
+ function cacheOf(api: StudioApi): Map<string, EntrySummary[]> {
84
+ let cache = lists.get(api);
85
+ if (!cache) {
86
+ cache = new Map();
87
+ lists.set(api, cache);
88
+ }
89
+ return cache;
90
+ }
91
+
92
+ type Row = TreeNode & {
93
+ title: string;
94
+ entryId: string;
95
+ /** The line under the title, in words: list fields, state, when it was edited. */
96
+ meta: string;
97
+ /** The same line in parts, so the state's word can take its colour. */
98
+ fields: string[];
99
+ state: EntryState | null;
100
+ after: string[];
101
+ /** The entry open beside the list. */
102
+ current: boolean;
103
+ children: Row[];
104
+ };
105
+
106
+ /** The ways to sort: the collection's order, title, last edit, and each of its list fields. */
107
+ function sortOptions(collection: CollectionManifest): { id: string; label: string }[] {
108
+ const { orderBy } = collection.presentation;
109
+ const fields = (collection.presentation.listFields ?? []).filter(
110
+ (key) => !TITLE_KEYS.has(key) && key !== orderBy,
111
+ );
112
+ return [
113
+ ...(orderBy ? [{ id: `field:${orderBy}`, label: labelFor(collection, orderBy) }] : []),
114
+ { id: 'title', label: 'Title' },
115
+ { id: 'updatedAt', label: 'Last edited' },
116
+ ...fields.map((key) => ({ id: `field:${key}`, label: labelFor(collection, key) })),
117
+ ];
118
+ }
119
+
120
+ /** Dates and edits newest first; everything else A to Z. */
121
+ function descriptorOf(collection: CollectionManifest, column: string): SortDescriptor {
122
+ const dated =
123
+ column === 'updatedAt' ||
124
+ collection.fields?.some(
125
+ (f) => `field:${String(f.path[0])}` === column && f.path.length === 1 && f.kind === 'date',
126
+ );
127
+ return { column, direction: dated ? 'descending' : 'ascending' };
128
+ }
129
+
130
+ export function ListPane({
131
+ collection,
132
+ currentId,
133
+ }: {
134
+ collection: CollectionManifest;
135
+ /** The entry open beside the list, or null. */
136
+ currentId: string | null;
137
+ }) {
138
+ const { api, cloud, manifest } = useStudio();
139
+ const { zone } = siteZone(manifest);
140
+ const { drafts } = useDrafts();
141
+ const memory = memoryOf(api, collection.name);
142
+ const heading = useRef<HTMLHeadingElement>(null);
143
+ useLeadHeading(collection.label, heading);
144
+ const [entries, setEntries] = useState<EntrySummary[] | null>(
145
+ () => cacheOf(api).get(collection.name) ?? null,
146
+ );
147
+ const [failed, setFailed] = useState(false);
148
+ const [query, setQuery] = useState(memory.query);
149
+ const [filter, setFilter] = useState<Filter>(memory.filter);
150
+ const [sort, setSort] = useState(memory.sort ?? String(defaultSort(collection).column));
151
+ const scroller = useRef<HTMLDivElement>(null);
152
+
153
+ useEffect(() => {
154
+ let live = true;
155
+ const read = () => {
156
+ api.listEntries(collection.name).then(
157
+ (value) => {
158
+ cacheOf(api).set(collection.name, value);
159
+ if (live) {
160
+ setEntries(value);
161
+ setFailed(false);
162
+ }
163
+ },
164
+ () => {
165
+ if (live) setFailed(true);
166
+ },
167
+ );
168
+ };
169
+ read();
170
+ const stop = onEntriesChanged(collection.name, read);
171
+ return () => {
172
+ live = false;
173
+ stop();
174
+ };
175
+ }, [api, collection.name]);
176
+
177
+ // In the cloud, only what the member may edit, as the list screen shows (spec §7.5).
178
+ const visible = useMemo(
179
+ () => (entries ?? []).filter((entry) => entryShownTo(cloud, entry.path)),
180
+ [entries, cloud],
181
+ );
182
+ const hasDrafts = visible.some((entry) => entry.draft);
183
+ const needsAttention = visible.some((entry) => entry.status !== 'editable');
184
+ const hasScheduled = visible.some((entry) => hasUpcoming(entry.schedule));
185
+
186
+ // Fresh rows when the open entry changes: the tree reuses a row whose item is the same object.
187
+ const rows = useMemo<Row[]>(() => {
188
+ const needle = query.trim().toLowerCase();
189
+ const listKeys = (collection.presentation.listFields ?? []).filter(
190
+ (key) => !TITLE_KEYS.has(key),
191
+ );
192
+ return visible
193
+ .filter(
194
+ (entry) =>
195
+ (needle === '' ||
196
+ entry.title.toLowerCase().includes(needle) ||
197
+ entry.id.toLowerCase().includes(needle)) &&
198
+ (filter === 'all' ||
199
+ (filter === 'drafts' && entry.draft === true) ||
200
+ (filter === 'attention' && entry.status !== 'editable') ||
201
+ (filter === 'scheduled' && hasUpcoming(entry.schedule))),
202
+ )
203
+ .sort(compareBy(descriptorOf(collection, sort)))
204
+ .map((entry) => {
205
+ const fields = listKeys.map((key) => formatListValue(entry.fields[key])).filter(Boolean);
206
+ const state = entryState(
207
+ collection,
208
+ {
209
+ draft: entry.draft === true,
210
+ times: rowData(collection.presentation.schedule, entry.schedule),
211
+ newDraft: isNewInDraft(drafts, entry.path),
212
+ },
213
+ { zone, cloud: Boolean(cloud) },
214
+ );
215
+ const after = [
216
+ kindText(entry.status),
217
+ entry.updatedAt ? `edited ${formatRelative(entry.updatedAt)}` : '',
218
+ ].filter(Boolean);
219
+ return {
220
+ id: entry.id,
221
+ title: entry.title,
222
+ entryId: entry.id,
223
+ meta: [...fields, stateText(state), ...after].filter(Boolean).join(' · '),
224
+ fields,
225
+ state,
226
+ after,
227
+ current: entry.id === currentId,
228
+ children: [],
229
+ };
230
+ });
231
+ }, [visible, query, filter, sort, collection, currentId, zone, drafts, cloud]);
232
+
233
+ // In order, whole and editable: then rows move. Searching, filtering or another sort
234
+ // would leave neighbours out of sight, and a member who can't see every entry can't
235
+ // place one among them.
236
+ const orderBy = collection.presentation.orderBy;
237
+ const ordering =
238
+ orderBy !== undefined &&
239
+ sort === `field:${orderBy}` &&
240
+ query.trim() === '' &&
241
+ filter === 'all' &&
242
+ collection.editable &&
243
+ entries !== null &&
244
+ visible.length === entries.length;
245
+ const [problem, setProblem] = useState<string | null>(null);
246
+ const [announcement, setAnnouncement] = useState('');
247
+ const announce = (text: string) => {
248
+ // A repeated message must still be read out: clear, then set.
249
+ setAnnouncement('');
250
+ window.setTimeout(() => {
251
+ setAnnouncement(text);
252
+ }, 50);
253
+ };
254
+
255
+ const move = useCallback(
256
+ async (row: Row, after: string | null, how: string) => {
257
+ setProblem(null);
258
+ // A move can write the open entry's number, and its unsaved edits would then conflict.
259
+ if (hasUnsavedChanges()) {
260
+ const message = 'Save the open entry first, then put entries in order.';
261
+ setProblem(message);
262
+ announce(message);
263
+ return;
264
+ }
265
+ const result = await api.reorderEntry({
266
+ collection: collection.name,
267
+ id: row.entryId,
268
+ after,
269
+ });
270
+ if (!result.ok) {
271
+ setProblem(result.message);
272
+ announce(result.message);
273
+ return;
274
+ }
275
+ const { index, of } = result.position;
276
+ announce(`${row.title} moved${how}, position ${String(index)} of ${String(of)}.`);
277
+ if (result.changed.length > 0) entriesRewritten(collection.name, result.changed);
278
+ },
279
+ [api, collection.name],
280
+ );
281
+
282
+ const step = (row: Row, direction: 'up' | 'down') => {
283
+ const index = rows.findIndex((r) => r.id === row.id);
284
+ if (direction === 'up') {
285
+ if (index <= 0) announce(`${row.title} is already first.`);
286
+ else void move(row, rows[index - 2]?.entryId ?? null, ' up');
287
+ } else if (index === -1 || index >= rows.length - 1) {
288
+ announce(`${row.title} is already last.`);
289
+ } else {
290
+ void move(row, rows[index + 1]?.entryId ?? null, ' down');
291
+ }
292
+ };
293
+
294
+ const onRowKeyDown = (row: Row, event: KeyboardEvent<HTMLDivElement>) => {
295
+ if (!ordering || !event.altKey) return;
296
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
297
+ event.preventDefault();
298
+ event.stopPropagation();
299
+ step(row, event.key === 'ArrowUp' ? 'up' : 'down');
300
+ }
301
+ };
302
+
303
+ // Always given to the tree and switched with `isDisabled`: it calls the hooks only
304
+ // when it has them, so taking them away mid-render would break the order of its hooks.
305
+ const { dragAndDropHooks } = useDragAndDrop<Row>({
306
+ isDisabled: !ordering,
307
+ acceptedDragTypes: [ENTRY_TYPE],
308
+ getItems: (keys) => [...keys].map((key) => ({ [ENTRY_TYPE]: String(key) })),
309
+ // Between rows only: a flat list has nothing to drop a row into.
310
+ getDropOperation: (target) =>
311
+ target.type === 'item' && target.dropPosition === 'on' ? 'cancel' : 'move',
312
+ onMove: (event) => {
313
+ const row = rows.find((r) => r.id === String([...event.keys][0]));
314
+ const at = rows.findIndex((r) => r.id === String(event.target.key));
315
+ if (!row || at === -1 || event.target.dropPosition === 'on') return;
316
+ const after =
317
+ event.target.dropPosition === 'after' ? rows[at]?.entryId : rows[at - 1]?.entryId;
318
+ if (after === row.entryId || (after === undefined && rows[0] === row)) return;
319
+ void move(row, after ?? null, '');
320
+ },
321
+ });
322
+
323
+ // Back where it was scrolled, before the pane is painted.
324
+ useLayoutEffect(() => {
325
+ if (scroller.current) scroller.current.scrollTop = memory.scrollTop;
326
+ }, [memory]);
327
+ useEffect(() => {
328
+ remember(api, collection.name, { query, filter, sort });
329
+ }, [api, collection.name, query, filter, sort]);
330
+
331
+ const options = sortOptions(collection);
332
+ const filters = [
333
+ { id: 'all', label: 'All entries' },
334
+ ...(hasDrafts ? [{ id: 'drafts', label: 'Drafts' }] : []),
335
+ ...(needsAttention ? [{ id: 'attention', label: 'Needs a developer' }] : []),
336
+ ...(hasScheduled ? [{ id: 'scheduled', label: 'Scheduled' }] : []),
337
+ ];
338
+ const newEntry =
339
+ collection.editable && mayCreateIn(cloud, collection)
340
+ ? hrefFor({ screen: 'new', collection: collection.name })
341
+ : null;
342
+
343
+ return (
344
+ <PaneFrame
345
+ title={collection.label}
346
+ headingRef={heading}
347
+ icon={collectionIcon(collection)}
348
+ actions={
349
+ newEntry && (
350
+ <ButtonLink variant="quiet" icon="add" href={newEntry}>
351
+ New entry
352
+ </ButtonLink>
353
+ )
354
+ }
355
+ scrollRef={scroller}
356
+ onScroll={(event) => {
357
+ remember(api, collection.name, { scrollTop: event.currentTarget.scrollTop });
358
+ }}
359
+ // The list's tools stay in the header as the list scrolls beneath it.
360
+ toolbar={
361
+ <div className="flex flex-col gap-2 px-4 pt-1 pb-2">
362
+ <TextField label="Search" type="search" icon="search" value={query} onChange={setQuery} />
363
+ <div className="-ml-3 flex flex-wrap items-center gap-1">
364
+ <Menu
365
+ label="Sort"
366
+ triggerText={`Sort: ${options.find((o) => o.id === sort)?.label ?? 'Title'}`}
367
+ choice={{
368
+ title: 'Sort by',
369
+ options,
370
+ selected: sort,
371
+ onChange: setSort,
372
+ }}
373
+ items={[]}
374
+ onAction={() => undefined}
375
+ />
376
+ {filters.length > 1 && (
377
+ <Menu
378
+ label="Show"
379
+ triggerText={`Show: ${filters.find((f) => f.id === filter)?.label ?? 'All entries'}`}
380
+ choice={{
381
+ title: 'Show',
382
+ options: filters,
383
+ selected: filter,
384
+ onChange: (id) => {
385
+ setFilter(id as Filter);
386
+ },
387
+ }}
388
+ items={[]}
389
+ onAction={() => undefined}
390
+ />
391
+ )}
392
+ </div>
393
+ {filter === 'scheduled' && (
394
+ <Link href={hrefFor({ screen: 'scheduled' })} className="text-small">
395
+ Everything scheduled on the site
396
+ </Link>
397
+ )}
398
+ {problem && (
399
+ <p role="alert" className="text-small text-danger">
400
+ {problem}
401
+ </p>
402
+ )}
403
+ </div>
404
+ }
405
+ >
406
+ <div className="pt-2 pb-3">
407
+ {failed && !entries ? (
408
+ <p role="alert" className="px-4 text-small text-danger">
409
+ Couldn’t load {collection.label.toLowerCase()}.
410
+ </p>
411
+ ) : !entries ? (
412
+ <p className="px-4 text-small text-content-muted">Loading…</p>
413
+ ) : (
414
+ <Tree<Row>
415
+ label={collection.label}
416
+ items={rows}
417
+ // Rows run edge to edge, with the pane's padding inside them.
418
+ inset={4}
419
+ // A row's name is this text, so it carries what the row shows: the title first, for type-ahead.
420
+ textValue={(row) =>
421
+ [row.title, row.meta, row.current ? 'open now' : ''].filter(Boolean).join(', ')
422
+ }
423
+ onAction={(row) => {
424
+ navigate({ screen: 'edit', collection: collection.name, id: row.entryId });
425
+ }}
426
+ currentKey={currentId}
427
+ emptyState={visible.length === 0 ? 'No entries yet.' : 'Nothing matches.'}
428
+ onRowKeyDown={onRowKeyDown}
429
+ {...(orderBy ? { dragAndDropHooks } : {})}
430
+ renderRow={(row) => (
431
+ <>
432
+ <span className="min-w-0 flex-1 px-2 py-1">
433
+ <span className="block truncate text-small font-semibold text-content">
434
+ {row.title}
435
+ </span>
436
+ {row.meta && (
437
+ <span className="block truncate text-caption text-content-muted">
438
+ {row.fields.join(' · ')}
439
+ {row.fields.length > 0 && row.state ? ' · ' : ''}
440
+ <StateWords state={row.state} />
441
+ {(row.fields.length > 0 || row.state) && row.after.length > 0 ? ' · ' : ''}
442
+ {row.after.join(' · ')}
443
+ </span>
444
+ )}
445
+ </span>
446
+ {ordering && (
447
+ <Menu
448
+ label={`More actions for ${row.title}`}
449
+ items={[
450
+ { id: 'up', label: 'Move up', icon: 'move-up', isDisabled: rows[0] === row },
451
+ {
452
+ id: 'down',
453
+ label: 'Move down',
454
+ icon: 'move-down',
455
+ isDisabled: rows.at(-1) === row,
456
+ },
457
+ ]}
458
+ onAction={(action) => {
459
+ step(row, action === 'up' ? 'up' : 'down');
460
+ }}
461
+ />
462
+ )}
463
+ </>
464
+ )}
465
+ />
466
+ )}
467
+ </div>
468
+ {/* The count only while the list is narrowed: beside an entry, one on every load would be
469
+ noise. Rows move only while it isn't, so a move's news shares the one region. */}
470
+ <p className="sr-only" role="status">
471
+ {entries && (query.trim() !== '' || filter !== 'all')
472
+ ? `${String(rows.length)} of ${String(visible.length)} entries shown.`
473
+ : announcement}
474
+ </p>
475
+ </PaneFrame>
476
+ );
477
+ }