@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,738 @@
1
+ import type { CollectionManifest, MoveResult, PageTreeNode, Position } from '@vespercms/core';
2
+ import {
3
+ Button,
4
+ ButtonLink,
5
+ Combobox,
6
+ cx,
7
+ Dialog,
8
+ Menu,
9
+ TextField,
10
+ Tree,
11
+ useDragAndDrop,
12
+ type Key,
13
+ type MenuItem,
14
+ type Option,
15
+ } from '@vespercms/ui';
16
+ import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react';
17
+ import { useStudio } from '../app/context.tsx';
18
+ import { onEntriesChanged } from '../app/entries-changed.ts';
19
+ import { mayCreateIn, treeCovered } from '../cloud/access.ts';
20
+ import { useDrafts } from '../cloud/editor.tsx';
21
+ import { siteZone } from '../schedule/times.ts';
22
+ import { useLoad } from '../app/load.ts';
23
+ import { hrefFor, navigate } from '../app/router.ts';
24
+ import { collectionIcon } from '../app/collection-icon.ts';
25
+ import { usePane } from '../app/pane-context.ts';
26
+ import { Screen } from '../app/Screen.tsx';
27
+ import { StateWords, entryState, isNewInDraft, kindText } from './status.tsx';
28
+
29
+ /*
30
+ * The outline of a tree collection (spec §6.15.2), in place of the flat
31
+ * entry list. One row per page: its title, its address in muted text, its
32
+ * status in words. Calm by default: collapsed beyond the first level.
33
+ *
34
+ * Every action is in the row's "…" menu, and the keyboard reaches all of
35
+ * them: arrows move and expand, Enter opens a page, Alt+↑/↓ move it among
36
+ * its siblings. Dragging is offered and never required. Each move is
37
+ * announced politely ("Nursing moved under Academics, position 1 of 3").
38
+ *
39
+ * Among panes it's the collection's pane, the same outline with every action,
40
+ * and the page open beside it is marked and in view.
41
+ *
42
+ * Search narrows it to the pages whose title or slug matches, under the
43
+ * pages above them, all open. Dragging is off while it does: a drop between
44
+ * the rows left would land among siblings that are out of sight.
45
+ */
46
+
47
+ /** A node as the outline shows it: keyed by its path in the tree, with its entry ID apart. */
48
+ type Row = Omit<PageTreeNode, 'id' | 'children'> & {
49
+ id: string;
50
+ entryId?: string;
51
+ /** The page open beside the outline. */
52
+ current?: boolean;
53
+ children: Row[];
54
+ };
55
+
56
+ type Moving = { row: Row; parent: string };
57
+
58
+ const TOP = '/';
59
+
60
+ const PAGE_TYPE = 'application/x-vesper-page';
61
+
62
+ export function PageTree({
63
+ collection,
64
+ currentId = null,
65
+ }: {
66
+ collection: CollectionManifest;
67
+ /** The page open beside the outline, among panes. */
68
+ currentId?: string | null;
69
+ }) {
70
+ const { api, cloud, manifest } = useStudio();
71
+ const { drafts } = useDrafts();
72
+ const pane = usePane();
73
+ const [load, reload] = useLoad(() => api.pageTree(collection.name), collection.name);
74
+ // A row knows no schedule times, so a scheduled collection's rows never claim Live.
75
+ const stateOf = (row: Row) =>
76
+ row.entryId === undefined
77
+ ? null
78
+ : entryState(
79
+ collection,
80
+ { draft: row.draft === true, times: null, newDraft: isNewInDraft(drafts, row.file) },
81
+ { zone: siteZone(manifest).zone, cloud: Boolean(cloud) },
82
+ );
83
+ // A save beside the outline can change a title or a status.
84
+ useEffect(() => onEntriesChanged(collection.name, reload), [collection.name, reload]);
85
+ const [expanded, setExpanded] = useState<Set<Key>>(() => new Set());
86
+ const [announcement, setAnnouncement] = useState('');
87
+ const [problem, setProblem] = useState<string | null>(null);
88
+ const [moving, setMoving] = useState<Moving | null>(null);
89
+ const [deleting, setDeleting] = useState<Row | null>(null);
90
+ const [query, setQuery] = useState('');
91
+ const needle = query.trim().toLowerCase();
92
+
93
+ // In the cloud a folder grant shows its branches, and the pages above them for context,
94
+ // which the member can open but not change (spec §6.15.2, §7.5).
95
+ const covered = useCallback(
96
+ (path: string) => treeCovered(cloud, collection.name, path),
97
+ [cloud, collection.name],
98
+ );
99
+ const prunes = cloud !== undefined && cloud.member.role !== 'viewer' && !cloud.wholeSite;
100
+ const rows = useMemo(() => {
101
+ if (load.state !== 'ready') return [];
102
+ const all = toRows(load.value.nodes);
103
+ return prunes ? pruneRows(all, covered) : all;
104
+ }, [load, prunes, covered]);
105
+ const byPath = useMemo(() => new Map(flatten(rows).map((row) => [row.path, row])), [rows]);
106
+ const byKey = useMemo(() => new Map(flatten(rows).map((row) => [row.id, row])), [rows]);
107
+ const maxDepth = load.state === 'ready' ? load.value.maxDepth : undefined;
108
+ const editable = collection.editable;
109
+ // Fresh rows when the open page changes: the tree reuses a row whose item is the same object.
110
+ const shown = useMemo(
111
+ () => withCurrent(matching(rows, needle), currentId),
112
+ [rows, needle, currentId],
113
+ );
114
+ const matches = needle ? flatten(rows).filter((row) => hits(row, needle)).length : 0;
115
+ const currentKey =
116
+ currentId === null
117
+ ? null
118
+ : (flatten(rows).find((row) => row.entryId === currentId)?.id ?? null);
119
+
120
+ // The open page's folders are open, so it's in view.
121
+ const [revealed, setRevealed] = useState<string | null>(null);
122
+ if (currentKey !== null && revealed !== currentKey) {
123
+ setRevealed(currentKey);
124
+ const folders = ancestorsOf(byKey.get(currentKey)?.path ?? '');
125
+ if (folders.some((key) => !expanded.has(key))) setExpanded(new Set([...expanded, ...folders]));
126
+ }
127
+
128
+ const announce = (text: string) => {
129
+ // A repeated message must still be read out: clear, then set.
130
+ setAnnouncement('');
131
+ window.setTimeout(() => {
132
+ setAnnouncement(text);
133
+ }, 50);
134
+ };
135
+
136
+ const reorder = useCallback(
137
+ async (row: Row, move: { direction: 'up' | 'down' } | { index: number }) => {
138
+ setProblem(null);
139
+ const result = await api.reorderPage({
140
+ collection: collection.name,
141
+ path: row.path,
142
+ ...move,
143
+ });
144
+ if (!result.ok) {
145
+ setProblem(result.message);
146
+ announce(result.message);
147
+ return;
148
+ }
149
+ const how = 'direction' in move ? ` ${move.direction}` : '';
150
+ announce(`${row.title} moved${how}, position ${positionText(result.position)}.`);
151
+ reload();
152
+ },
153
+ [api, collection.name, reload],
154
+ );
155
+
156
+ const createSection = async (row: Row) => {
157
+ setProblem(null);
158
+ const result = await api.createSection(collection.name, row.path);
159
+ if (result.ok) navigate({ screen: 'edit', collection: collection.name, id: result.id });
160
+ else setProblem('message' in result ? result.message : 'The section page couldn’t be created.');
161
+ };
162
+
163
+ const onMenu = (row: Row, action: string) => {
164
+ if (action === 'open' && row.entryId !== undefined) {
165
+ navigate({ screen: 'edit', collection: collection.name, id: row.entryId });
166
+ } else if (action === 'child') {
167
+ navigate({ screen: 'new', collection: collection.name, parent: row.path });
168
+ } else if (action === 'duplicate' && row.entryId !== undefined) {
169
+ navigate({ screen: 'copy', collection: collection.name, id: row.entryId });
170
+ } else if (action === 'move') {
171
+ setMoving({ row, parent: parentOf(row.path) });
172
+ } else if (action === 'up' || action === 'down') {
173
+ void reorder(row, { direction: action });
174
+ } else if (action === 'section') {
175
+ void createSection(row);
176
+ } else if (action === 'delete') {
177
+ setDeleting(row);
178
+ }
179
+ };
180
+
181
+ const onRowKeyDown = (row: Row, event: KeyboardEvent<HTMLDivElement>) => {
182
+ // Shift+F10 or the menu key opens the row's "…" menu, as a context menu would.
183
+ if ((event.key === 'F10' && event.shiftKey) || event.key === 'ContextMenu') {
184
+ event.preventDefault();
185
+ event.stopPropagation();
186
+ const button = (event.target as HTMLElement).querySelector<HTMLButtonElement>(
187
+ `button[aria-label="More actions for ${CSS.escape(row.title)}"]`,
188
+ );
189
+ if (button) {
190
+ // Enter on the button, not a click: React Aria takes a script's click
191
+ // for a screen reader's and waits a frame to move focus, which can then
192
+ // stay on the menu instead of its first item. (Down Arrow would be the
193
+ // outline's: the row handles arrow keys before the button sees them.)
194
+ button.focus();
195
+ for (const type of ['keydown', 'keyup']) {
196
+ button.dispatchEvent(
197
+ new KeyboardEvent(type, { key: 'Enter', bubbles: true, cancelable: true }),
198
+ );
199
+ }
200
+ }
201
+ return;
202
+ }
203
+ if (!editable || !event.altKey || row.path === '' || !covered(parentOf(row.path))) return;
204
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
205
+ event.preventDefault();
206
+ event.stopPropagation();
207
+ void reorder(row, { direction: event.key === 'ArrowUp' ? 'up' : 'down' });
208
+ }
209
+ };
210
+
211
+ // Drag and drop, on top of the menu: between siblings reorders, onto a page moves under it.
212
+ // A move to another parent opens the same dialog as "Move to…", so its effects are confirmed.
213
+ // Off while searching through `isDisabled`, never by leaving the hooks out: the tree calls
214
+ // them only when it's given them, so dropping them mid-render breaks the order of its hooks.
215
+ // A page travels as its own type, never text: the entry beside the outline would type
216
+ // its key into a Markdown field, and the outline would take a list's row for a page.
217
+ const { dragAndDropHooks } = useDragAndDrop<Row>({
218
+ isDisabled: needle !== '',
219
+ acceptedDragTypes: [PAGE_TYPE],
220
+ getItems: (keys) => [...keys].map((key) => ({ [PAGE_TYPE]: String(key) })),
221
+ onMove: (event) => {
222
+ // A drop onto a page is onItemDrop's: it moves the page under that one.
223
+ if (event.target.dropPosition === 'on') return;
224
+ const row = byKey.get(String([...event.keys][0]));
225
+ const target = byKey.get(String(event.target.key));
226
+ if (!row || !target) return;
227
+ if (row.path === '') {
228
+ announce('The home page always comes first.');
229
+ return;
230
+ }
231
+ const parent = parentOf(target.path);
232
+ if (parent !== parentOf(row.path)) {
233
+ setMoving({ row, parent });
234
+ return;
235
+ }
236
+ const siblings = siblingsOf(row, rows, byPath);
237
+ const from = siblings.indexOf(row);
238
+ const slot = siblings.indexOf(target) + (event.target.dropPosition === 'after' ? 1 : 0);
239
+ const to = slot > from ? slot - 1 : slot;
240
+ if (from === -1 || to === from) return;
241
+ void reorder(row, { index: to });
242
+ },
243
+ onItemDrop: (event) => {
244
+ const target = byKey.get(String(event.target.key));
245
+ const item = event.items[0];
246
+ if (!target || item?.kind !== 'text') return;
247
+ void item.getText(PAGE_TYPE).then((key) => {
248
+ const row = byKey.get(key);
249
+ if (row && row !== target && row.path !== '') setMoving({ row, parent: target.path });
250
+ });
251
+ },
252
+ });
253
+
254
+ const newPage =
255
+ editable && mayCreateIn(cloud, collection)
256
+ ? hrefFor({ screen: 'new', collection: collection.name })
257
+ : null;
258
+
259
+ return (
260
+ <Screen
261
+ title={collection.label}
262
+ icon={collectionIcon(collection)}
263
+ afterglow={load.state === 'ready' && rows.length === 0}
264
+ // Among panes, New page heads the pane, quiet: the entry beside it has the primary button.
265
+ {...(pane && newPage
266
+ ? {
267
+ bar: (
268
+ <ButtonLink variant="quiet" icon="add" href={newPage}>
269
+ New page
270
+ </ButtonLink>
271
+ ),
272
+ }
273
+ : {})}
274
+ >
275
+ {!editable && (
276
+ <p className={cx('max-w-2xl text-content-muted', pane ? 'mb-3 text-small' : 'mb-6')}>
277
+ {collection.label} can be read here but not edited.
278
+ </p>
279
+ )}
280
+ {load.state === 'failed' && (
281
+ <p role="alert" className={cx('text-danger', pane ? 'mb-3 text-small' : 'mb-6')}>
282
+ Couldn’t load the pages: {load.message}
283
+ </p>
284
+ )}
285
+ {pane ? (
286
+ <>
287
+ <TextField
288
+ label="Search"
289
+ type="search"
290
+ icon="search"
291
+ value={query}
292
+ onChange={setQuery}
293
+ className="mb-3"
294
+ />
295
+ <p className="mb-3 px-1 text-small text-content-muted">
296
+ Each page’s “…” menu adds a page under it or moves it. Alt+↑ and Alt+↓ change its order.
297
+ </p>
298
+ </>
299
+ ) : (
300
+ <div className="mb-6 flex flex-wrap items-end gap-4">
301
+ <p className="max-w-2xl text-content-muted">
302
+ Pages are listed as they nest on the site. Each page’s “…” menu adds a page under it,
303
+ moves it or changes its order. From a page, the right arrow or Shift+F10 reaches its
304
+ menu, and Alt+↑ and Alt+↓ move it.
305
+ </p>
306
+ {newPage && (
307
+ // The screen's one primary action.
308
+ <ButtonLink variant="primary" icon="add" href={newPage} className="ml-auto">
309
+ New page
310
+ </ButtonLink>
311
+ )}
312
+ </div>
313
+ )}
314
+ {!pane && (
315
+ <TextField
316
+ label="Search"
317
+ type="search"
318
+ icon="search"
319
+ value={query}
320
+ onChange={setQuery}
321
+ className="mb-6 w-full max-w-xs"
322
+ />
323
+ )}
324
+ {problem && (
325
+ <p role="alert" className="mb-4 text-danger">
326
+ {problem}
327
+ </p>
328
+ )}
329
+ {load.state === 'loading' ? (
330
+ <p className="text-small text-content-muted">Loading pages…</p>
331
+ ) : (
332
+ // Among panes the rows run edge to edge, out through the pane's padding and with it inside them.
333
+ <div className={pane ? '-mx-3' : undefined}>
334
+ <Tree<Row>
335
+ label={collection.label}
336
+ items={shown}
337
+ inset={pane ? 4 : 1}
338
+ // A row's name is this text: the title first, for type-ahead.
339
+ textValue={(row) => (row.current ? `${row.title}, open now` : row.title)}
340
+ // While searching, every page above a match is open, and the outline's own state waits.
341
+ expandedKeys={needle ? allKeys(shown) : expanded}
342
+ onExpandedChange={(keys) => {
343
+ if (!needle) setExpanded(keys);
344
+ }}
345
+ onAction={(row) => {
346
+ if (row.entryId !== undefined) onMenu(row, 'open');
347
+ }}
348
+ onRowKeyDown={onRowKeyDown}
349
+ {...(editable && covered('') ? { dragAndDropHooks } : {})}
350
+ emptyState={rows.length === 0 ? 'No pages yet.' : 'No pages match.'}
351
+ currentKey={currentKey}
352
+ renderRow={(row) => {
353
+ const state = stateOf(row);
354
+ const words = statusOf(row);
355
+ return (
356
+ <>
357
+ <span className="min-w-0 flex-1 px-1">
358
+ <span className="block truncate font-semibold text-content">{row.title}</span>
359
+ <span className="block truncate font-mono text-caption text-content-muted">
360
+ {row.url ?? `${row.path}/`}
361
+ </span>
362
+ </span>
363
+ <span className="shrink-0 text-small text-content-muted">
364
+ <StateWords state={state} />
365
+ {state && words ? ' · ' : ''}
366
+ {words}
367
+ </span>
368
+ <Menu
369
+ label={`More actions for ${row.title}`}
370
+ items={menuItems(byKey.get(row.id) ?? row, rows, byPath, {
371
+ editable,
372
+ maxDepth,
373
+ covered,
374
+ })}
375
+ onAction={(action) => {
376
+ onMenu(row, action);
377
+ }}
378
+ />
379
+ </>
380
+ );
381
+ }}
382
+ />
383
+ </div>
384
+ )}
385
+ <p className="sr-only" role="status" aria-live="polite">
386
+ {announcement}
387
+ </p>
388
+ <p className="sr-only" role="status">
389
+ {needle ? `${String(matches)} ${matches === 1 ? 'page matches' : 'pages match'}.` : ''}
390
+ </p>
391
+
392
+ {moving && (
393
+ <MoveDialog
394
+ collection={collection}
395
+ row={moving.row}
396
+ rows={rows}
397
+ initialParent={moving.parent}
398
+ onClose={() => {
399
+ setMoving(null);
400
+ }}
401
+ onMoved={(result) => {
402
+ setMoving(null);
403
+ if (!result.ok) return;
404
+ const where = result.parent ? `under ${result.parent}` : 'to the top level';
405
+ announce(`${result.title} moved ${where}, position ${positionText(result.position)}.`);
406
+ setExpanded((keys) => new Set([...keys, ...ancestorsOf(result.path)]));
407
+ reload();
408
+ }}
409
+ />
410
+ )}
411
+ {deleting && (
412
+ <DeleteDialog
413
+ collection={collection}
414
+ row={deleting}
415
+ onClose={() => {
416
+ setDeleting(null);
417
+ }}
418
+ onDeleted={() => {
419
+ announce(`${deleting.title} deleted.`);
420
+ setDeleting(null);
421
+ reload();
422
+ }}
423
+ />
424
+ )}
425
+ </Screen>
426
+ );
427
+ }
428
+
429
+ function MoveDialog({
430
+ collection,
431
+ row,
432
+ rows,
433
+ initialParent,
434
+ onClose,
435
+ onMoved,
436
+ }: {
437
+ collection: CollectionManifest;
438
+ row: Row;
439
+ rows: Row[];
440
+ initialParent: string;
441
+ onClose: () => void;
442
+ onMoved: (result: MoveResult) => void;
443
+ }) {
444
+ const { api } = useStudio();
445
+ const [parent, setParent] = useState(initialParent === '' ? TOP : initialParent);
446
+ const [slug, setSlug] = useState(row.slug);
447
+ const [plan, setPlan] = useState<Extract<MoveResult, { ok: true }> | null>(null);
448
+ const [problem, setProblem] = useState<string | null>(null);
449
+ const [busy, setBusy] = useState(false);
450
+
451
+ const options: Option[] = [
452
+ { value: TOP, label: 'Top level' },
453
+ ...flatten(rows)
454
+ .filter((r) => r.path !== '' && r.path !== row.path && !r.path.startsWith(`${row.path}/`))
455
+ .map((r) => ({ value: r.path, label: `${'— '.repeat(Math.max(0, r.depth - 1))}${r.title}` })),
456
+ ];
457
+ const request = {
458
+ collection: collection.name,
459
+ path: row.path,
460
+ parent: parent === TOP ? '' : parent,
461
+ slug,
462
+ };
463
+
464
+ const review = async () => {
465
+ setProblem(null);
466
+ setBusy(true);
467
+ try {
468
+ const result = await api.movePage(request, { dryRun: true });
469
+ if (result.ok) setPlan(result);
470
+ else setProblem(result.message);
471
+ } finally {
472
+ setBusy(false);
473
+ }
474
+ };
475
+ const confirm = async () => {
476
+ setBusy(true);
477
+ try {
478
+ const result = await api.movePage(request);
479
+ if (result.ok) onMoved(result);
480
+ else {
481
+ setPlan(null);
482
+ setProblem(result.message);
483
+ }
484
+ } finally {
485
+ setBusy(false);
486
+ }
487
+ };
488
+
489
+ return (
490
+ <Dialog
491
+ title={plan ? `Move “${row.title}”?` : `Move “${row.title}”`}
492
+ isOpen
493
+ onOpenChange={(open) => {
494
+ if (!open) onClose();
495
+ }}
496
+ role={plan ? 'alertdialog' : 'dialog'}
497
+ >
498
+ {plan ? (
499
+ <>
500
+ <p>{planText(plan)}</p>
501
+ <div className="flex flex-wrap justify-end gap-2">
502
+ <Button
503
+ onPress={() => {
504
+ setPlan(null);
505
+ }}
506
+ >
507
+ Back
508
+ </Button>
509
+ <Button variant="primary" onPress={() => void confirm()} isPending={busy}>
510
+ Move
511
+ </Button>
512
+ </div>
513
+ </>
514
+ ) : (
515
+ <form
516
+ className="flex flex-col gap-4"
517
+ onSubmit={(event) => {
518
+ event.preventDefault();
519
+ void review();
520
+ }}
521
+ >
522
+ <Combobox
523
+ label="Put it under"
524
+ description="Search by title. Choose Top level to make it a top-level page."
525
+ options={options}
526
+ value={parent}
527
+ onChange={(value) => {
528
+ setParent(value ?? TOP);
529
+ }}
530
+ />
531
+ <TextField
532
+ label="Address"
533
+ description={`The last part of the page’s address: lower-case letters, numbers and dashes.`}
534
+ value={slug}
535
+ onChange={setSlug}
536
+ />
537
+ {problem && (
538
+ <p role="alert" className="text-danger">
539
+ {problem}
540
+ </p>
541
+ )}
542
+ <div className="flex flex-wrap justify-end gap-2">
543
+ <Button onPress={onClose}>Cancel</Button>
544
+ <Button type="submit" variant="primary" isPending={busy}>
545
+ Review move
546
+ </Button>
547
+ </div>
548
+ </form>
549
+ )}
550
+ </Dialog>
551
+ );
552
+ }
553
+
554
+ function DeleteDialog({
555
+ collection,
556
+ row,
557
+ onClose,
558
+ onDeleted,
559
+ }: {
560
+ collection: CollectionManifest;
561
+ row: Row;
562
+ onClose: () => void;
563
+ onDeleted: () => void;
564
+ }) {
565
+ const { api } = useStudio();
566
+ const [problem, setProblem] = useState<string | null>(null);
567
+ const remove = async () => {
568
+ const result = await api.deleteEntry(collection.name, row.entryId ?? '', row.version ?? '');
569
+ if (result.ok) onDeleted();
570
+ else setProblem('message' in result ? result.message : 'The page couldn’t be deleted.');
571
+ };
572
+ const under = flatten(row.children).filter((r) => r.entryId !== undefined).length;
573
+ return (
574
+ <Dialog
575
+ title={`Delete “${row.title}”?`}
576
+ role="alertdialog"
577
+ isOpen
578
+ onOpenChange={(open) => {
579
+ if (!open) onClose();
580
+ }}
581
+ >
582
+ <p>
583
+ This deletes {row.file ?? 'the page'}.
584
+ {under > 0
585
+ ? ` The ${String(under)} ${under === 1 ? 'page' : 'pages'} under it stay, in a folder with no section page.`
586
+ : ''}{' '}
587
+ In local mode the file is removed from disk; git can bring it back.
588
+ </p>
589
+ {problem && (
590
+ <p role="alert" className="text-danger">
591
+ {problem}
592
+ </p>
593
+ )}
594
+ <div className="flex flex-wrap justify-end gap-2">
595
+ <Button onPress={onClose}>Cancel</Button>
596
+ <Button variant="danger" onPress={() => void remove()}>
597
+ Delete
598
+ </Button>
599
+ </div>
600
+ </Dialog>
601
+ );
602
+ }
603
+
604
+ /** The confirmation's words (spec §6.15.4): what moves, what changes with it. */
605
+ export function planText(plan: Extract<MoveResult, { ok: true }>): string {
606
+ const others = plan.pages - (plan.id !== undefined ? 1 : 0);
607
+ const what =
608
+ others > 0
609
+ ? `“${plan.title}” and its ${String(others)} ${others === 1 ? 'page' : 'pages'}`
610
+ : `“${plan.title}”`;
611
+ const where = plan.parent ? `under “${plan.parent}”` : 'to the top level';
612
+ const address = plan.to ? ` Its address becomes ${plan.to}.` : '';
613
+ const links =
614
+ plan.links > 0
615
+ ? ` ${String(plan.links)} ${plan.links === 1 ? 'link' : 'links'} on other pages will be updated.`
616
+ : '';
617
+ const visitors =
618
+ plan.redirects > 0 ? ' Visitors to the old addresses will be sent to the new ones.' : '';
619
+ return `This moves ${what} ${where}.${address}${links}${visitors}`;
620
+ }
621
+
622
+ function menuItems(
623
+ row: Row,
624
+ rows: Row[],
625
+ byPath: Map<string, Row>,
626
+ options: {
627
+ editable: boolean;
628
+ maxDepth: number | undefined;
629
+ /** Whether the member may change pages at a tree path (the cloud's grants). */
630
+ covered: (path: string) => boolean;
631
+ },
632
+ ): MenuItem[] {
633
+ const items: MenuItem[] = [];
634
+ if (row.entryId !== undefined) items.push({ id: 'open', label: 'Open', icon: 'open' });
635
+ if (!options.editable || !options.covered(row.path)) return items;
636
+ const siblings = siblingsOf(row, rows, byPath);
637
+ const index = siblings.indexOf(row);
638
+ const canHaveChild = options.maxDepth === undefined || row.depth < options.maxDepth;
639
+ if (row.path !== '' && canHaveChild)
640
+ items.push({ id: 'child', label: 'Add child page', icon: 'add-child' });
641
+ if (row.entryId === undefined)
642
+ items.push({ id: 'section', label: 'Create section page', icon: 'add-child' });
643
+ // A copy goes beside its original, in the parent's folder, which the grant must cover.
644
+ if (row.entryId !== undefined && options.covered(parentOf(row.path)))
645
+ items.push({ id: 'duplicate', label: 'Duplicate', icon: 'duplicate' });
646
+ if (row.path !== '') {
647
+ items.push({ id: 'move', label: 'Move to…', icon: 'move-to' });
648
+ // The order of siblings lives in their parent's folder, which the grant must cover too.
649
+ if (options.covered(parentOf(row.path))) {
650
+ items.push({ id: 'up', label: 'Move up', icon: 'move-up', isDisabled: index <= 0 });
651
+ items.push({
652
+ id: 'down',
653
+ label: 'Move down',
654
+ icon: 'move-down',
655
+ isDisabled: index === siblings.length - 1,
656
+ });
657
+ }
658
+ }
659
+ if (row.entryId !== undefined && row.path !== '') {
660
+ items.push({ id: 'delete', label: 'Delete…', icon: 'delete', isDanger: true });
661
+ }
662
+ return items;
663
+ }
664
+
665
+ /** What the row is besides its state: a folder with no page, or what can be done with it. */
666
+ function statusOf(row: Row): string {
667
+ if (row.entryId === undefined) return 'No section page';
668
+ return kindText(row.status);
669
+ }
670
+
671
+ function toRows(nodes: PageTreeNode[]): Row[] {
672
+ return nodes.map(({ id, children, ...node }) => ({
673
+ ...node,
674
+ id: node.path || '(home)',
675
+ ...(id !== undefined ? { entryId: id } : {}),
676
+ children: toRows(children),
677
+ }));
678
+ }
679
+
680
+ function siblingsOf(row: Row, rows: Row[], byPath: Map<string, Row>): Row[] {
681
+ const parent = parentOf(row.path);
682
+ return parent === '' ? rows.filter((r) => r.path !== '') : (byPath.get(parent)?.children ?? []);
683
+ }
684
+
685
+ /** Pages a grant covers, and the pages above them, so each branch keeps its place. */
686
+ function pruneRows(rows: Row[], covered: (path: string) => boolean): Row[] {
687
+ return rows.flatMap((row) => {
688
+ const children = pruneRows(row.children, covered);
689
+ return covered(row.path) || children.length > 0 ? [{ ...row, children }] : [];
690
+ });
691
+ }
692
+
693
+ function hits(row: Row, needle: string): boolean {
694
+ return row.title.toLowerCase().includes(needle) || row.slug.toLowerCase().includes(needle);
695
+ }
696
+
697
+ /** Pages that match, with the pages above them, so each keeps its place. */
698
+ function matching(rows: Row[], needle: string): Row[] {
699
+ if (!needle) return rows;
700
+ return rows.flatMap((row) => {
701
+ const children = matching(row.children, needle);
702
+ return hits(row, needle) || children.length > 0 ? [{ ...row, children }] : [];
703
+ });
704
+ }
705
+
706
+ function allKeys(rows: Row[]): Set<Key> {
707
+ return new Set(
708
+ flatten(rows)
709
+ .filter((row) => row.children.length > 0)
710
+ .map((row) => row.id),
711
+ );
712
+ }
713
+
714
+ /** Copies of the rows, so each is drawn again with the page that's open now. */
715
+ function withCurrent(rows: Row[], currentId: string | null): Row[] {
716
+ return rows.map((row) => ({
717
+ ...row,
718
+ current: row.entryId !== undefined && row.entryId === currentId,
719
+ children: withCurrent(row.children, currentId),
720
+ }));
721
+ }
722
+
723
+ function flatten(rows: Row[]): Row[] {
724
+ return rows.flatMap((row) => [row, ...flatten(row.children)]);
725
+ }
726
+
727
+ function parentOf(path: string): string {
728
+ return path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
729
+ }
730
+
731
+ function ancestorsOf(path: string): string[] {
732
+ const parts = path.split('/');
733
+ return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/'));
734
+ }
735
+
736
+ function positionText(position: Position): string {
737
+ return `${String(position.index)} of ${String(position.of)}`;
738
+ }