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