@mosetta/editor 0.0.0-stage → 0.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/mosetta.json +6 -0
- package/package.json +35 -3
- package/prefabs/workbench/prefab.json +11 -0
- package/prefabs/workbench/versions/1.json +120 -0
- package/prefabs/workbench/versions/2.json +13 -0
- package/prefabs/workbench/versions/3.json +19 -0
- package/prefabs/workbench/versions/4.json +29 -0
- package/prefabs/workbench/versions/5.json +29 -0
- package/tiles/canvas/canvas.component.tsx +2038 -0
- package/tiles/canvas/canvas.css +973 -0
- package/tiles/canvas/canvasTools.ts +66 -0
- package/tiles/canvas/chromeNodes.ts +35 -0
- package/tiles/canvas/ghosts.ts +33 -0
- package/tiles/canvas/insertPopover.tsx +56 -0
- package/tiles/canvas/popoverPlacement.ts +104 -0
- package/tiles/canvas/popoverSize.ts +87 -0
- package/tiles/canvas/providerChrome.tsx +191 -0
- package/tiles/canvas/scrollWatch.ts +43 -0
- package/tiles/canvas/settingsPopover.tsx +257 -0
- package/tiles/canvas/settingsTabs.ts +78 -0
- package/tiles/canvas/toolPanel.tsx +86 -0
- package/tiles/content/autoBind.ts +61 -0
- package/tiles/content/blockPage.css +390 -0
- package/tiles/content/blockPage.tsx +379 -0
- package/tiles/content/blockPicker.css +215 -0
- package/tiles/content/blockPicker.tsx +501 -0
- package/tiles/content/blockUpgrades.ts +145 -0
- package/tiles/content/blockVariants.ts +185 -0
- package/tiles/content/blockVocabulary.ts +77 -0
- package/tiles/content/catalogVersions.ts +106 -0
- package/tiles/content/componentPreview.css +82 -0
- package/tiles/content/componentPreview.tsx +249 -0
- package/tiles/content/content.server.ts +593 -0
- package/tiles/content/contentCommon.ts +133 -0
- package/tiles/content/disconnected.ts +56 -0
- package/tiles/content/distributions.css +256 -0
- package/tiles/content/distributions.tsx +271 -0
- package/tiles/content/editingAutosave.ts +125 -0
- package/tiles/content/editingClipboard.ts +185 -0
- package/tiles/content/editingConflicts.ts +223 -0
- package/tiles/content/editingHotkeys.ts +59 -0
- package/tiles/content/editingNotices.ts +105 -0
- package/tiles/content/formRoot.ts +18 -0
- package/tiles/content/fuzzySearch.ts +121 -0
- package/tiles/content/mediaUpload.ts +55 -0
- package/tiles/content/moduleSiblings.ts +9 -0
- package/tiles/content/mountedTree.ts +136 -0
- package/tiles/content/nodeSettings.css +483 -0
- package/tiles/content/nodeSettings.tsx +963 -0
- package/tiles/content/pasteText.ts +131 -0
- package/tiles/content/prefabFields.tsx +158 -0
- package/tiles/content/prefabParts.ts +105 -0
- package/tiles/content/previewEnvironment.ts +46 -0
- package/tiles/content/projectConfig.ts +46 -0
- package/tiles/content/projectPrefabs.ts +57 -0
- package/tiles/content/publishModal.css +109 -0
- package/tiles/content/publishModal.tsx +92 -0
- package/tiles/content/refsEditing.ts +412 -0
- package/tiles/content/scaledStage.ts +63 -0
- package/tiles/content/searchField.css +32 -0
- package/tiles/content/searchField.tsx +32 -0
- package/tiles/content/settingsDraft.ts +178 -0
- package/tiles/content/settingsIssues.tsx +124 -0
- package/tiles/content/themeScope.ts +72 -0
- package/tiles/content/themeVariables.ts +47 -0
- package/tiles/content/tooltipPlacement.ts +17 -0
- package/tiles/content/tooltips.tsx +131 -0
- package/tiles/content/treeMounter.ts +33 -0
- package/tiles/content/updatesModal.css +124 -0
- package/tiles/content/updatesModal.tsx +110 -0
- package/tiles/content/useCtrlHeld.ts +24 -0
- package/tiles/content/useEditingSession.ts +115 -0
- package/tiles/content/useMountedTree.ts +47 -0
- package/tiles/content/workbenchScale.css +27 -0
- package/tiles/hostFallback/hostFallback.component.tsx +32 -0
- package/tiles/hostFallback/hostFallback.css +12 -0
- package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
- package/tiles/page/page.component.tsx +7 -0
- package/tiles/page/page.script.tsx +5 -0
- package/tiles/pageModules/pageModules.component.tsx +740 -0
- package/tiles/pageModules/pageModules.css +383 -0
- package/tiles/pageModules/pageModules.icon.svg +1 -0
- package/tiles/pageModules/servedPage.ts +465 -0
- package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
- package/tiles/pagesPanel/pagesPanel.css +713 -0
- package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
- package/tiles/pagesPanel/pagesSearch.ts +194 -0
- package/tiles/pagesPanel/routeTree.ts +98 -0
- package/tiles/previewBridge/landing.ts +66 -0
- package/tiles/previewBridge/previewAgent.ts +457 -0
- package/tiles/previewBridge/previewBridge.component.tsx +137 -0
- package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
- package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
- package/tiles/previewBridge/standIn.ts +22 -0
- package/tiles/treeView/treeView.component.tsx +514 -0
- package/tiles/treeView/treeView.css +303 -0
- package/tiles/treeView/treeView.editor.tsx +12 -0
- package/tiles/workbench/assistant.css +401 -0
- package/tiles/workbench/assistant.tsx +516 -0
- package/tiles/workbench/assistantChat.ts +165 -0
- package/tiles/workbench/dictation.ts +354 -0
- package/tiles/workbench/projectInfo.ts +72 -0
- package/tiles/workbench/recent.ts +67 -0
- package/tiles/workbench/workbench.component.tsx +476 -0
- package/tiles/workbench/workbench.css +376 -0
- package/tiles/workbench/workbenchAddress.ts +202 -0
- package/tiles/workbench/workspace.ts +775 -0
- package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
- package/tiles/workbenchEditor/workbenchEditor.css +370 -0
- package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
- package/tiles/workbenchHome/workbenchHome.css +271 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,1645 @@
|
|
|
1
|
+
import { defineComponentSide, markInternalTile } from '@mosetta/react';
|
|
2
|
+
import { ensureQueryParams } from '@mosetta/runtime/dom';
|
|
3
|
+
import { useServerAction } from '@mosetta/react/server-action';
|
|
4
|
+
import { Link, tileScopeClass, useComponent, useDi, useDiOptional, useRoute, useSettings } from '@mosetta/react';
|
|
5
|
+
import { ROUTER_TOKEN } from '@mosetta/runtime/client';
|
|
6
|
+
import { DI_CONTAINER_TOKEN } from '@mosetta/di';
|
|
7
|
+
import '../content/workbenchScale.css';
|
|
8
|
+
import './pagesPanel.css';
|
|
9
|
+
import { createContext, ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
10
|
+
import { createPortal } from 'react-dom';
|
|
11
|
+
import { EServerActionStart } from '@mosetta/server-action/client';
|
|
12
|
+
import { ContentKind, EditingSession, editingSessionHubOf, ENTITY_NAME_RE, IPageDoc, PREFAB_CANVAS_PATTERN } from '@mosetta/content';
|
|
13
|
+
import type { ITileReference } from '@mosetta/core';
|
|
14
|
+
import {
|
|
15
|
+
createPageDraftToken,
|
|
16
|
+
createPageToken,
|
|
17
|
+
deletePageToken,
|
|
18
|
+
discardPageDraftToken,
|
|
19
|
+
forkPageDraftToken,
|
|
20
|
+
IPageDraftListItem,
|
|
21
|
+
IPageListEntry,
|
|
22
|
+
listComponentsToken,
|
|
23
|
+
listPagesToken,
|
|
24
|
+
loadPageDraftToken,
|
|
25
|
+
loadPageToken,
|
|
26
|
+
publishPageToken,
|
|
27
|
+
refreshLiveSiteToken,
|
|
28
|
+
} from '../content/contentCommon';
|
|
29
|
+
import { useEditingSession } from '../content/useEditingSession';
|
|
30
|
+
import { TooltipHost } from '../content/tooltips';
|
|
31
|
+
import { SearchField } from '../content/searchField';
|
|
32
|
+
import { noticesOf } from '../content/editingNotices';
|
|
33
|
+
import { PublishModal } from '../content/publishModal';
|
|
34
|
+
import { WORKSPACE_TOKEN } from '../workbench/workspace';
|
|
35
|
+
import { basePathOf, DISTRIBUTIONS_SEGMENT, IOpenDoc, kindOfSegment, parseWorkbenchPath, pathOfView } from '../workbench/workbenchAddress';
|
|
36
|
+
import { DistributionsView } from '../content/distributions';
|
|
37
|
+
import { fittingTags, groupKeyOf, IPathMatch, nameFromPattern, rankPaths } from './pagesSearch';
|
|
38
|
+
import { buildRouteTree, IRouteTreeNode } from './routeTree';
|
|
39
|
+
|
|
40
|
+
/** the search term rides the address like the open tabs do */
|
|
41
|
+
export const PAGES_SEARCH_PARAM = 'pagesSearch';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What the panel lists — the site's PAGES, or its PREFABS — and the words it uses for them. One
|
|
45
|
+
* panel for both: the same versions, drafts, publishing and canvases; a prefab is named, not
|
|
46
|
+
* routed, and opens in a canvas as a page whose root is its own.
|
|
47
|
+
*/
|
|
48
|
+
interface IEntityWords {
|
|
49
|
+
readonly kind: ContentKind;
|
|
50
|
+
readonly noun: string;
|
|
51
|
+
readonly title: string;
|
|
52
|
+
readonly searchParam: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PAGE_WORDS: IEntityWords = { kind: 'pages', noun: 'page', title: 'Pages', searchParam: PAGES_SEARCH_PARAM };
|
|
56
|
+
const PREFAB_WORDS: IEntityWords = { kind: 'prefabs', noun: 'prefab', title: 'Prefabs', searchParam: 'prefabsSearch' };
|
|
57
|
+
|
|
58
|
+
const EntityWords = createContext<IEntityWords>(PAGE_WORDS);
|
|
59
|
+
|
|
60
|
+
interface IPagesPanelSettings {
|
|
61
|
+
/** what the panel lists: the pages (the default), or the prefabs */
|
|
62
|
+
readonly kind?: ContentKind;
|
|
63
|
+
/**
|
|
64
|
+
* The COMPACT list: routes grouped by their first segment, one row each — the quick picker a
|
|
65
|
+
* popover shows beside the editor. Without it, the pages stand as a TREE of their routes, the
|
|
66
|
+
* way the Pages view of the workbench shows them (a prefab has no route: its list is flat
|
|
67
|
+
* either way).
|
|
68
|
+
*/
|
|
69
|
+
readonly compact?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* The list's kind read from a parameter of the page's ROUTE (`/workbench/{kind}` names it
|
|
72
|
+
* `kind`): one list page of the workbench shows the pages or the prefabs by its address
|
|
73
|
+
* (ADR-0051 §4). A parameter naming neither says so instead of a list.
|
|
74
|
+
*/
|
|
75
|
+
readonly kindParam?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** a page as the panel presents it: the ROUTE is the identity on screen, the name stays the key */
|
|
79
|
+
interface IRouteVm {
|
|
80
|
+
readonly entity: IPageListEntry;
|
|
81
|
+
/** what the row shows: the live route, or the first draft's route while nothing is published */
|
|
82
|
+
readonly pattern: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function routeVmOf(entity: IPageListEntry, kind: ContentKind): IRouteVm {
|
|
86
|
+
// a prefab has no route: its name is what the row shows
|
|
87
|
+
if (kind === 'prefabs') return { entity, pattern: entity.name };
|
|
88
|
+
const draftPattern = entity.drafts.find((draft) => draft.pattern !== null)?.pattern ?? null;
|
|
89
|
+
return { entity, pattern: entity.livePattern ?? draftPattern ?? `/${entity.name}` };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The one way ANY document reaches a canvas from this panel: the WORKSPACE opens it — the tab
|
|
94
|
+
* already showing it comes forward, or a fresh tab loads it. Drafts open for editing; published
|
|
95
|
+
* revisions open read-only. Without a workspace (the panel standing alone, a specimen), the
|
|
96
|
+
* document opens into the shared session the way it always could.
|
|
97
|
+
*/
|
|
98
|
+
function useDocOpener() {
|
|
99
|
+
const load = useServerAction({ token: loadPageDraftToken, start: EServerActionStart.START_MANUAL });
|
|
100
|
+
const loadRev = useServerAction({ token: loadPageToken, start: EServerActionStart.START_MANUAL });
|
|
101
|
+
const { kind } = useContext(EntityWords);
|
|
102
|
+
const { session } = useEditingSession();
|
|
103
|
+
const hub = editingSessionHubOf(useDi(DI_CONTAINER_TOKEN));
|
|
104
|
+
const workspace = useDiOptional(WORKSPACE_TOKEN);
|
|
105
|
+
const router = useDiOptional(ROUTER_TOKEN);
|
|
106
|
+
const route = useRoute();
|
|
107
|
+
// without a workspace — the Pages page of the workbench — a document opens by a MOVE to the
|
|
108
|
+
// editor's page (ADR-0051 §4), in place through the client router; a realm with neither
|
|
109
|
+
// (a specimen, a page of its own) opens into the shared session, the way it always could
|
|
110
|
+
const moveToEditor = (route && router?.active) ? (doc: IOpenDoc): void => {
|
|
111
|
+
const base = basePathOf(route.pattern ?? null, route.path ?? '/');
|
|
112
|
+
const { pathname, version } = pathOfView(base, { view: 'editor', ...doc });
|
|
113
|
+
const query = new URLSearchParams(version).toString();
|
|
114
|
+
void router.navigate(query ? `${pathname}?${query}` : pathname);
|
|
115
|
+
} : null;
|
|
116
|
+
|
|
117
|
+
/** a draft with its doc already in hand (a fresh duplicate) — opened without a load */
|
|
118
|
+
const openWithDoc = (name: string, draftId: string, doc: IPageDoc): void => {
|
|
119
|
+
if (workspace) workspace.open({ kind, page: name, draftId }, doc);
|
|
120
|
+
else if (moveToEditor) moveToEditor({ kind, page: name, draftId });
|
|
121
|
+
else session.openPage(name, draftId, doc, kind);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const openDraft = (name: string, draftId: string): void => {
|
|
125
|
+
if (workspace) {
|
|
126
|
+
workspace.open({ kind, page: name, draftId });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (moveToEditor) {
|
|
130
|
+
moveToEditor({ kind, page: name, draftId });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (session.snapshot().draftId === draftId) return;
|
|
134
|
+
const loaded = load.isSuccess() && load.serverData.ok ? load.serverData.draft : null;
|
|
135
|
+
// a cached answer will not re-run the effect below — open it here instead of waiting
|
|
136
|
+
if (loaded && loaded.name === name && loaded.id === draftId) session.openPage(loaded.name, loaded.id, loaded.doc, kind);
|
|
137
|
+
else load.start!({ name, draftId, kind });
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** a published revision, read-only: the session opens it in the inspect mode */
|
|
141
|
+
const openRevision = (name: string, rev: number): void => {
|
|
142
|
+
if (workspace) {
|
|
143
|
+
workspace.open({ kind, page: name, revision: rev });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (moveToEditor) {
|
|
147
|
+
moveToEditor({ kind, page: name, revision: rev });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const held = session.snapshot();
|
|
151
|
+
if (held.kind === kind && held.pageName === name && held.revision === rev) return;
|
|
152
|
+
const loaded = loadRev.isSuccess() && loadRev.serverData.ok ? loadRev.serverData.page : null;
|
|
153
|
+
if (loaded && loaded.name === name && loaded.rev === rev) session.openRevision(loaded.name, loaded.rev, loaded.doc, kind);
|
|
154
|
+
else loadRev.start!({ name, rev, kind });
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The page is GONE (deleted whole): every session holding one of its documents — a draft or a
|
|
159
|
+
* read-only revision — lets go. A workspace closes the tab whose session went empty.
|
|
160
|
+
*/
|
|
161
|
+
const closeDocsOf = (name: string): void => {
|
|
162
|
+
const held = hub ? hub.keys().map((key) => hub.peek(key)).filter((s): s is EditingSession => s !== null) : [session];
|
|
163
|
+
for (const s of held) {
|
|
164
|
+
const state = s.snapshot();
|
|
165
|
+
if (state.kind === kind && state.pageName === name) s.closePage();
|
|
166
|
+
}
|
|
167
|
+
// …and it leaves the recent list: a way to a page that is no longer there is no way
|
|
168
|
+
workspace?.forget({ kind, page: name });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A DRAFT is gone from the store — discarded, or published (it became revision `live`). Every
|
|
173
|
+
* session still holding it lets go: a discarded draft leaves its tab closing, a published one
|
|
174
|
+
* reopens there as the read-only revision it became. A session that kept the draft would
|
|
175
|
+
* autosave the next edit into a draft that no longer exists.
|
|
176
|
+
*/
|
|
177
|
+
const retireDraft = (name: string, draftId: string, live?: number): void => {
|
|
178
|
+
const held = hub ? hub.keys().map((key) => hub.peek(key)).filter((s): s is EditingSession => s !== null) : [session];
|
|
179
|
+
for (const s of held) {
|
|
180
|
+
const state = s.snapshot();
|
|
181
|
+
if (state.kind !== kind || state.pageName !== name || state.draftId !== draftId) continue;
|
|
182
|
+
if (live !== undefined && state.doc) s.openRevision(name, live, state.doc, kind);
|
|
183
|
+
else s.closePage();
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (workspace) return;
|
|
189
|
+
if (load.isSuccess() && load.serverData.ok) {
|
|
190
|
+
const { draft } = load.serverData;
|
|
191
|
+
session.openPage(draft.name, draft.id, draft.doc, kind);
|
|
192
|
+
}
|
|
193
|
+
}, [load, session]);
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (workspace) return;
|
|
196
|
+
if (loadRev.isSuccess() && loadRev.serverData.ok) {
|
|
197
|
+
const { page } = loadRev.serverData;
|
|
198
|
+
session.openRevision(page.name, page.rev, page.doc, kind);
|
|
199
|
+
}
|
|
200
|
+
}, [loadRev, session]);
|
|
201
|
+
|
|
202
|
+
return { openDraft, openWithDoc, openRevision, closeDocsOf, retireDraft };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** the matched characters lit inside the text — what the search found is what the eye finds */
|
|
206
|
+
function Lit({ text, indices }: { text: string; indices: readonly number[] }) {
|
|
207
|
+
if (indices.length === 0) return <>{text}</>;
|
|
208
|
+
const lit = new Set(indices);
|
|
209
|
+
const parts: ReactNode[] = [];
|
|
210
|
+
let run = '';
|
|
211
|
+
let runLit = lit.has(0);
|
|
212
|
+
const flush = (key: number) => {
|
|
213
|
+
if (!run) return;
|
|
214
|
+
parts.push(runLit ? <mark key={key}>{run}</mark> : run);
|
|
215
|
+
run = '';
|
|
216
|
+
};
|
|
217
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
218
|
+
const isLit = lit.has(i);
|
|
219
|
+
if (isLit !== runLit) {
|
|
220
|
+
flush(i);
|
|
221
|
+
runLit = isLit;
|
|
222
|
+
}
|
|
223
|
+
run += text[i];
|
|
224
|
+
}
|
|
225
|
+
flush(text.length);
|
|
226
|
+
return <>{parts}</>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function HighlightedPath({ path, match }: { path: string; match: IPathMatch | null }) {
|
|
230
|
+
return <Lit text={path}
|
|
231
|
+
indices={match?.indices ?? []} />;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const ChevronIcon = (
|
|
235
|
+
<svg viewBox="0 0 16 16"
|
|
236
|
+
aria-hidden="true">
|
|
237
|
+
<path d="M6 3.5L10.5 8 6 12.5"
|
|
238
|
+
fill="none"
|
|
239
|
+
stroke="currentColor"
|
|
240
|
+
strokeWidth="1.6"
|
|
241
|
+
strokeLinecap="round"
|
|
242
|
+
strokeLinejoin="round" />
|
|
243
|
+
</svg>
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// stacked sheets: the versions of one page, kept behind the row
|
|
247
|
+
const VersionsIcon = (
|
|
248
|
+
<svg viewBox="0 0 16 16"
|
|
249
|
+
aria-hidden="true">
|
|
250
|
+
<rect x="2.5"
|
|
251
|
+
y="5.5"
|
|
252
|
+
width="8"
|
|
253
|
+
height="8"
|
|
254
|
+
rx="1.2"
|
|
255
|
+
fill="none"
|
|
256
|
+
stroke="currentColor"
|
|
257
|
+
strokeWidth="1.3" />
|
|
258
|
+
<path d="M5.5 5.5V3.7A1.2 1.2 0 0 1 6.7 2.5h5.6a1.2 1.2 0 0 1 1.2 1.2v5.6a1.2 1.2 0 0 1-1.2 1.2h-1.8"
|
|
259
|
+
fill="none"
|
|
260
|
+
stroke="currentColor"
|
|
261
|
+
strokeWidth="1.3" />
|
|
262
|
+
</svg>
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// a BRANCH, not two sheets: "a new draft grows from this version" — two stacked sheets read as
|
|
266
|
+
// "copy to the clipboard"
|
|
267
|
+
const NewDraftIcon = (
|
|
268
|
+
<svg viewBox="0 0 16 16"
|
|
269
|
+
aria-hidden="true">
|
|
270
|
+
<circle cx="5"
|
|
271
|
+
cy="3.6"
|
|
272
|
+
r="1.6"
|
|
273
|
+
fill="none"
|
|
274
|
+
stroke="currentColor"
|
|
275
|
+
strokeWidth="1.4" />
|
|
276
|
+
<circle cx="5"
|
|
277
|
+
cy="12.4"
|
|
278
|
+
r="1.6"
|
|
279
|
+
fill="none"
|
|
280
|
+
stroke="currentColor"
|
|
281
|
+
strokeWidth="1.4" />
|
|
282
|
+
<circle cx="11.2"
|
|
283
|
+
cy="5.6"
|
|
284
|
+
r="1.6"
|
|
285
|
+
fill="none"
|
|
286
|
+
stroke="currentColor"
|
|
287
|
+
strokeWidth="1.4" />
|
|
288
|
+
<path d="M5 5.2v5.6M11.2 7.2c0 2.6-6.2 1.8-6.2 3.6"
|
|
289
|
+
fill="none"
|
|
290
|
+
stroke="currentColor"
|
|
291
|
+
strokeWidth="1.4" />
|
|
292
|
+
</svg>
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const EditIcon = (
|
|
296
|
+
<svg viewBox="0 0 16 16"
|
|
297
|
+
aria-hidden="true">
|
|
298
|
+
<path d="M3 13l.8-3.2L10.6 3a1.4 1.4 0 0 1 2 0l.4.4a1.4 1.4 0 0 1 0 2L6.2 12.2 3 13z"
|
|
299
|
+
fill="none"
|
|
300
|
+
stroke="currentColor"
|
|
301
|
+
strokeWidth="1.4"
|
|
302
|
+
strokeLinejoin="round" />
|
|
303
|
+
<path d="M9.6 4l2.4 2.4"
|
|
304
|
+
fill="none"
|
|
305
|
+
stroke="currentColor"
|
|
306
|
+
strokeWidth="1.4" />
|
|
307
|
+
</svg>
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const OpenOutIcon = (
|
|
311
|
+
<svg viewBox="0 0 16 16"
|
|
312
|
+
aria-hidden="true">
|
|
313
|
+
<path d="M6.5 3.5H3.5v9h9V9.5M9 3.5h3.5V7M12.5 3.5L7.5 8.5"
|
|
314
|
+
fill="none"
|
|
315
|
+
stroke="currentColor"
|
|
316
|
+
strokeWidth="1.5"
|
|
317
|
+
strokeLinecap="round" />
|
|
318
|
+
</svg>
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const DeleteIcon = (
|
|
322
|
+
<svg viewBox="0 0 16 16"
|
|
323
|
+
aria-hidden="true">
|
|
324
|
+
<path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.7 8.5h5.6l.7-8.5M6.7 7v3.7M9.3 7v3.7"
|
|
325
|
+
fill="none"
|
|
326
|
+
stroke="currentColor"
|
|
327
|
+
strokeWidth="1.3" />
|
|
328
|
+
</svg>
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// a padlock: the page is the platform's, frozen
|
|
332
|
+
const LockIcon = (
|
|
333
|
+
<svg viewBox="0 0 16 16"
|
|
334
|
+
aria-hidden="true">
|
|
335
|
+
<rect x="3.5"
|
|
336
|
+
y="7"
|
|
337
|
+
width="9"
|
|
338
|
+
height="6.5"
|
|
339
|
+
rx="1.2"
|
|
340
|
+
fill="none"
|
|
341
|
+
stroke="currentColor"
|
|
342
|
+
strokeWidth="1.3" />
|
|
343
|
+
<path d="M5.5 7V5.2a2.5 2.5 0 0 1 5 0V7"
|
|
344
|
+
fill="none"
|
|
345
|
+
stroke="currentColor"
|
|
346
|
+
strokeWidth="1.3" />
|
|
347
|
+
</svg>
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const LOCKED_TOOLTIP = 'The platform\'s page: it updates with the platform, so it cannot be edited, republished or deleted here';
|
|
351
|
+
|
|
352
|
+
const PublishIcon = (
|
|
353
|
+
<svg viewBox="0 0 16 16"
|
|
354
|
+
aria-hidden="true">
|
|
355
|
+
<path d="M8 12.6V4M4.6 7.2L8 3.6l3.4 3.6"
|
|
356
|
+
fill="none"
|
|
357
|
+
stroke="currentColor"
|
|
358
|
+
strokeWidth="1.5" />
|
|
359
|
+
<path d="M3 13.6h10"
|
|
360
|
+
fill="none"
|
|
361
|
+
stroke="currentColor"
|
|
362
|
+
strokeWidth="1.5" />
|
|
363
|
+
</svg>
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The panel's FREEZE: an action on a row (publish, discard, duplicate, delete) and the listing's
|
|
368
|
+
* refetch after it are one operation to the author — the old list stays on screen until the new
|
|
369
|
+
* one arrives, and a second click on a row that is about to disappear does damage. So the whole
|
|
370
|
+
* list is frozen from the moment an action starts until the listing it changed has come back.
|
|
371
|
+
*/
|
|
372
|
+
interface IPanelBusy {
|
|
373
|
+
begin(): void;
|
|
374
|
+
/** `changed`: the action landed and changed the listing — the freeze holds until it is refetched */
|
|
375
|
+
end(changed: boolean): void;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** how long the freeze waits for the refetched listing before letting go anyway */
|
|
379
|
+
const LISTING_WAIT_MS = 5000;
|
|
380
|
+
|
|
381
|
+
/** a page's versions as the panel shows them: revisions oldest first, drafts oldest first */
|
|
382
|
+
function versionsOf(entity: IPageListEntry) {
|
|
383
|
+
const revisions = Object.entries(entity.meta.versions)
|
|
384
|
+
.map(([rev, status]) => ({ rev: Number(rev), status }))
|
|
385
|
+
.sort((a, b) => a.rev - b.rev);
|
|
386
|
+
const drafts = [...entity.drafts].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
387
|
+
return { revisions, drafts };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const draftLabel = (draft: IPageDraftListItem): string => draft.title ?? draft.id.slice(0, 8);
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* How many tags fit the collapsed row: the live tag always shows, then as many drafts as the row
|
|
394
|
+
* has room for, and a "…" when some are left out. Every tag is RENDERED (the ones that do not fit
|
|
395
|
+
* sit out of the flow, invisible), so their widths are known before deciding — the row re-decides
|
|
396
|
+
* whenever its width changes.
|
|
397
|
+
*/
|
|
398
|
+
/** the gap between tags — `.pages-panel__tags { gap }` (a constant, not read back: getComputedStyle
|
|
399
|
+
* is slow, and in a test DOM it can hang) */
|
|
400
|
+
const TAG_GAP = 4;
|
|
401
|
+
|
|
402
|
+
function useFittingTags(labels: readonly string[]): { ref: (el: HTMLDivElement | null) => void; shown: number } {
|
|
403
|
+
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
|
404
|
+
const [shown, setShown] = useState(labels.length);
|
|
405
|
+
const key = labels.join('\n');
|
|
406
|
+
const [width, setWidth] = useState(0);
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
const Observer = el?.ownerDocument.defaultView?.ResizeObserver;
|
|
409
|
+
if (!el || !Observer) return undefined;
|
|
410
|
+
const observer = new Observer(() => setWidth(el.clientWidth));
|
|
411
|
+
observer.observe(el);
|
|
412
|
+
return () => observer.disconnect();
|
|
413
|
+
}, [el]);
|
|
414
|
+
useLayoutEffect(() => {
|
|
415
|
+
if (!el) return;
|
|
416
|
+
const widthOf = (selector: string) => [...el.querySelectorAll<HTMLElement>(selector)].map((tag) => tag.offsetWidth);
|
|
417
|
+
const [live = 0] = widthOf('[data-tag-kind="live"]');
|
|
418
|
+
// the padlock of a frozen page sits before the live tag and takes its room from the drafts
|
|
419
|
+
const [lock = 0] = widthOf('[data-tag-kind="locked"]');
|
|
420
|
+
const drafts = widthOf('[data-tag-kind="draft"]');
|
|
421
|
+
const [more = 0] = widthOf('[data-tag-kind="more"]');
|
|
422
|
+
setShown(fittingTags({ room: el.clientWidth - (lock ? lock + TAG_GAP : 0), gap: TAG_GAP, live, drafts, more }));
|
|
423
|
+
}, [el, width, key]);
|
|
424
|
+
return { ref: setEl, shown };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* A row's action with a glyph AND a word (the tree has the room): a button, or a link that opens
|
|
429
|
+
* in a new tab when `href` is given. Clicks never reach the row behind it.
|
|
430
|
+
*/
|
|
431
|
+
function RouteButton({ testId, action, tooltip, disabled, danger, href, icon, children, onClick }: {
|
|
432
|
+
testId: string;
|
|
433
|
+
action: string;
|
|
434
|
+
tooltip: string;
|
|
435
|
+
disabled?: boolean;
|
|
436
|
+
danger?: boolean;
|
|
437
|
+
href?: string;
|
|
438
|
+
icon: ReactNode;
|
|
439
|
+
children: ReactNode;
|
|
440
|
+
onClick?: () => void;
|
|
441
|
+
}) {
|
|
442
|
+
const className = `pages-panel__route-button${danger ? ' pages-panel__route-button--danger' : ''}`;
|
|
443
|
+
if (href !== undefined) {
|
|
444
|
+
return (
|
|
445
|
+
<a className={className}
|
|
446
|
+
href={href}
|
|
447
|
+
target="_blank"
|
|
448
|
+
rel="noopener noreferrer"
|
|
449
|
+
data-test-id={testId}
|
|
450
|
+
data-action={action}
|
|
451
|
+
data-tooltip={tooltip}
|
|
452
|
+
onClick={(e) => e.stopPropagation()}>
|
|
453
|
+
{icon}
|
|
454
|
+
<span>{children}</span>
|
|
455
|
+
</a>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
return (
|
|
459
|
+
<button type="button"
|
|
460
|
+
className={className}
|
|
461
|
+
data-test-id={testId}
|
|
462
|
+
data-action={action}
|
|
463
|
+
data-tooltip={tooltip}
|
|
464
|
+
disabled={disabled}
|
|
465
|
+
onClick={(e) => {
|
|
466
|
+
e.stopPropagation();
|
|
467
|
+
onClick?.();
|
|
468
|
+
}}>
|
|
469
|
+
{icon}
|
|
470
|
+
<span>{children}</span>
|
|
471
|
+
</button>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** the address a version previews at: the page's own path with the version's token (no bridge) */
|
|
476
|
+
function previewHrefOf(kind: ContentKind, pattern: string, name: string, token: string): string {
|
|
477
|
+
return `${pattern}?previewToken=${kind === 'prefabs' ? `prefab.${name}` : name}.${token}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** a route an address can name: no parameters to fill in */
|
|
481
|
+
const isConcreteRoute = (pattern: string): boolean => !pattern.includes('{');
|
|
482
|
+
|
|
483
|
+
function ActionIcon({ testId, action, label, tooltip, disabled, onClick, children }: {
|
|
484
|
+
testId: string;
|
|
485
|
+
action: string;
|
|
486
|
+
/** the name a screen reader says */
|
|
487
|
+
label: string;
|
|
488
|
+
/** what a click will do — shown on hover (TooltipHost) */
|
|
489
|
+
tooltip: string;
|
|
490
|
+
disabled?: boolean;
|
|
491
|
+
onClick: () => void;
|
|
492
|
+
children: ReactNode;
|
|
493
|
+
}) {
|
|
494
|
+
return (
|
|
495
|
+
<button type="button"
|
|
496
|
+
className="pages-panel__icon"
|
|
497
|
+
data-test-id={testId}
|
|
498
|
+
data-action={action}
|
|
499
|
+
data-tooltip={tooltip}
|
|
500
|
+
aria-label={label}
|
|
501
|
+
disabled={disabled}
|
|
502
|
+
onClick={(e) => {
|
|
503
|
+
e.stopPropagation(); // the row behind the icon opens a version — an action is not that
|
|
504
|
+
onClick();
|
|
505
|
+
}}>
|
|
506
|
+
{children}
|
|
507
|
+
</button>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---- the delete-page confirmation ----------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* "Delete this page?" — asked in a modal that shows WHAT goes: the page's route and every
|
|
515
|
+
* revision and draft it holds, the way the panel lists them. Portalled to the document body: the
|
|
516
|
+
* panel freezes while the delete runs, and a frozen panel takes no clicks — the dialog must.
|
|
517
|
+
*/
|
|
518
|
+
function DeletePageModal({ host, route, deleting, error, onCancel, onConfirm }: {
|
|
519
|
+
/** the document the dialog goes into — the panel's own */
|
|
520
|
+
host: Document;
|
|
521
|
+
route: IRouteVm;
|
|
522
|
+
deleting: boolean;
|
|
523
|
+
error: string | null;
|
|
524
|
+
onCancel: () => void;
|
|
525
|
+
onConfirm: () => void;
|
|
526
|
+
}) {
|
|
527
|
+
const { revisions, drafts } = versionsOf(route.entity);
|
|
528
|
+
const { noun } = useContext(EntityWords);
|
|
529
|
+
const self = useComponent().reference;
|
|
530
|
+
const confirmRef = useRef<HTMLButtonElement>(null);
|
|
531
|
+
useEffect(() => confirmRef.current?.focus(), []);
|
|
532
|
+
// wrapped in the module's scope host: the panel's css is built under that class, and markup
|
|
533
|
+
// portalled onto the body would otherwise arrive unstyled
|
|
534
|
+
return createPortal(
|
|
535
|
+
<div className={tileScopeClass(self)}
|
|
536
|
+
style={{ display: 'contents' }}>
|
|
537
|
+
<div className="pages-panel__modal-overlay"
|
|
538
|
+
data-test-id="pages-delete-modal"
|
|
539
|
+
onClick={() => {
|
|
540
|
+
if (!deleting) onCancel();
|
|
541
|
+
}}
|
|
542
|
+
onKeyDown={(e) => {
|
|
543
|
+
if (e.key === 'Escape' && !deleting) onCancel();
|
|
544
|
+
}}>
|
|
545
|
+
<div className="pages-panel__modal pages-panel__delete"
|
|
546
|
+
role="alertdialog"
|
|
547
|
+
aria-label={`Delete this ${noun}`}
|
|
548
|
+
onClick={(e) => e.stopPropagation()}>
|
|
549
|
+
<div className="pages-panel__delete-title">Delete this {noun}?</div>
|
|
550
|
+
<div className="pages-panel__delete-text">
|
|
551
|
+
Do you really want to delete this {noun}? It goes with every version below, and this
|
|
552
|
+
cannot be undone.
|
|
553
|
+
{noun === 'prefab' ? ' Pages that show it keep their own blocks and lose its.' : ''}
|
|
554
|
+
</div>
|
|
555
|
+
<div className="pages-panel__delete-page"
|
|
556
|
+
data-test-id="pages-delete-summary">
|
|
557
|
+
<div className="pages-panel__delete-path">{route.pattern}</div>
|
|
558
|
+
{revisions.length > 0 ? (
|
|
559
|
+
<div className="pages-panel__delete-versions">
|
|
560
|
+
{revisions.map(({ rev, status }) => (
|
|
561
|
+
<span key={rev}
|
|
562
|
+
className={`pages-panel__tag pages-panel__tag--${status}`}
|
|
563
|
+
data-rev={rev}>
|
|
564
|
+
r{rev} · {status}
|
|
565
|
+
</span>
|
|
566
|
+
))}
|
|
567
|
+
</div>
|
|
568
|
+
) : null}
|
|
569
|
+
{drafts.length > 0 ? (
|
|
570
|
+
<div className="pages-panel__delete-versions">
|
|
571
|
+
{drafts.map((draft) => (
|
|
572
|
+
<span key={draft.id}
|
|
573
|
+
className="pages-panel__tag pages-panel__tag--draft"
|
|
574
|
+
data-draft-id={draft.id}>
|
|
575
|
+
{draftLabel(draft)}
|
|
576
|
+
</span>
|
|
577
|
+
))}
|
|
578
|
+
</div>
|
|
579
|
+
) : null}
|
|
580
|
+
</div>
|
|
581
|
+
{error ? <div className="pages-panel__error">{error}</div> : null}
|
|
582
|
+
<div className="pages-panel__delete-buttons">
|
|
583
|
+
<button type="button"
|
|
584
|
+
className="pages-panel__button"
|
|
585
|
+
data-test-id="pages-delete-cancel"
|
|
586
|
+
disabled={deleting}
|
|
587
|
+
onClick={onCancel}>
|
|
588
|
+
Cancel
|
|
589
|
+
</button>
|
|
590
|
+
<button ref={confirmRef}
|
|
591
|
+
type="button"
|
|
592
|
+
className="pages-panel__button pages-panel__button--danger"
|
|
593
|
+
data-test-id="pages-delete-confirm"
|
|
594
|
+
disabled={deleting}
|
|
595
|
+
onClick={onConfirm}>
|
|
596
|
+
{deleting ? 'Deleting…' : `Delete ${noun}`}
|
|
597
|
+
</button>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
</div>,
|
|
602
|
+
host.body,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ---- one route -----------------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
interface IRouteItemProps {
|
|
609
|
+
readonly busyPanel: IPanelBusy;
|
|
610
|
+
readonly route: IRouteVm;
|
|
611
|
+
/** the words on the row: the whole route (the compact list), or this level's segment (the tree) */
|
|
612
|
+
readonly text: string;
|
|
613
|
+
/** the matched characters inside `text` */
|
|
614
|
+
readonly lit: readonly number[];
|
|
615
|
+
/** the tree: how deep the row sits */
|
|
616
|
+
readonly tree: boolean;
|
|
617
|
+
readonly depth: number;
|
|
618
|
+
readonly expanded: boolean;
|
|
619
|
+
readonly highlighted: boolean;
|
|
620
|
+
readonly onToggle: () => void;
|
|
621
|
+
readonly onExpand: () => void;
|
|
622
|
+
readonly opener: ReturnType<typeof useDocOpener>;
|
|
623
|
+
readonly registerEl: (name: string, el: HTMLLIElement | null) => void;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* A route of the panel. In the TREE a click on the row unfolds its versions one level down (the
|
|
628
|
+
* chevron on the left does the same), and the canvas is reached by words: "Edit" on the row (the
|
|
629
|
+
* newest draft, else the live revision read-only), "Clone" and "Open" (a preview in a new tab) on
|
|
630
|
+
* every version row, "Open" on the row for the live page itself. In the COMPACT list — a picker —
|
|
631
|
+
* the row OPENS the page: its live revision, or its newest draft while nothing is published, and
|
|
632
|
+
* the versions sit behind a button at the row's end. Every version row opens that version. Folded,
|
|
633
|
+
* the row still names its versions as tags: the live one, then drafts as far as the row has room,
|
|
634
|
+
* then "…" (which unfolds). A tag opens its version too. The pages UNDER a page stay in view —
|
|
635
|
+
* only a folder (a path without a page of its own) folds its branch.
|
|
636
|
+
*/
|
|
637
|
+
function RouteItem({ busyPanel, route, text, lit, tree, depth, expanded, highlighted, onToggle, onExpand, opener, registerEl }: IRouteItemProps) {
|
|
638
|
+
const { entity, pattern } = route;
|
|
639
|
+
const { kind, noun } = useContext(EntityWords);
|
|
640
|
+
const notices = noticesOf(useDi(DI_CONTAINER_TOKEN));
|
|
641
|
+
const createDraft = useServerAction({ token: createPageDraftToken, start: EServerActionStart.START_MANUAL });
|
|
642
|
+
const publish = useServerAction({ token: publishPageToken, start: EServerActionStart.START_MANUAL });
|
|
643
|
+
const discard = useServerAction({ token: discardPageDraftToken, start: EServerActionStart.START_MANUAL });
|
|
644
|
+
const loadForFork = useServerAction({ token: loadPageDraftToken, start: EServerActionStart.START_MANUAL });
|
|
645
|
+
const fork = useServerAction({ token: forkPageDraftToken, start: EServerActionStart.START_MANUAL });
|
|
646
|
+
const remove = useServerAction({ token: deletePageToken, start: EServerActionStart.START_MANUAL });
|
|
647
|
+
const busy = createDraft.isLoading() || publish.isLoading() || discard.isLoading() || fork.isLoading()
|
|
648
|
+
|| remove.isLoading();
|
|
649
|
+
// the panel freezes while any of this row's actions runs, and until the listing it changed
|
|
650
|
+
// is back: which actions were in flight is remembered so their outcome decides the wait
|
|
651
|
+
const actions = [createDraft, publish, discard, fork, remove];
|
|
652
|
+
const inFlight = useRef(new Set<number>());
|
|
653
|
+
useEffect(() => {
|
|
654
|
+
const wasBusy = inFlight.current.size > 0;
|
|
655
|
+
actions.forEach((action, i) => {
|
|
656
|
+
if (action.isLoading()) inFlight.current.add(i);
|
|
657
|
+
});
|
|
658
|
+
if (!wasBusy && busy) busyPanel.begin();
|
|
659
|
+
if (wasBusy && !busy) {
|
|
660
|
+
const changed = [...inFlight.current].some((i) => {
|
|
661
|
+
const done = actions[i];
|
|
662
|
+
return done.isSuccess() && done.serverData.ok;
|
|
663
|
+
});
|
|
664
|
+
inFlight.current.clear();
|
|
665
|
+
busyPanel.end(changed);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
// a row that leaves mid-action (its page was deleted elsewhere) must not keep the panel frozen
|
|
669
|
+
useEffect(() => () => {
|
|
670
|
+
if (inFlight.current.size > 0) busyPanel.end(false);
|
|
671
|
+
}, []);
|
|
672
|
+
/** discarding a draft asks twice: the first click arms THIS draft's trash, the second one fires */
|
|
673
|
+
const [deleteArmed, setDeleteArmed] = useState<string | null>(null);
|
|
674
|
+
/** the whole page is deleted through a confirmation dialog that shows what goes */
|
|
675
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
676
|
+
/** the draft being published — the dialog asks the publication's name first */
|
|
677
|
+
const [publishingDraft, setPublishingDraft] = useState<IPageDraftListItem | null>(null);
|
|
678
|
+
/** duplicating a draft loads its doc first — this remembers which duplicate is in flight */
|
|
679
|
+
const forkingRef = useRef<string | null>(null);
|
|
680
|
+
/** the draft a discard or a publish is about — the canvases holding it let go when it lands */
|
|
681
|
+
const retiringRef = useRef<string | null>(null);
|
|
682
|
+
|
|
683
|
+
// a fresh draft duplicated from a REVISION is the start of editing — it opens where a draft opens
|
|
684
|
+
useEffect(() => {
|
|
685
|
+
if (createDraft.isSuccess() && createDraft.serverData.ok) {
|
|
686
|
+
const { draftId, page } = createDraft.serverData;
|
|
687
|
+
opener.openWithDoc(entity.name, draftId, page);
|
|
688
|
+
}
|
|
689
|
+
}, [createDraft]);
|
|
690
|
+
// a duplicate of a DRAFT just appears as a row: the fork lands, the listing refetches itself
|
|
691
|
+
useEffect(() => {
|
|
692
|
+
if (loadForFork.isSuccess() && loadForFork.serverData.ok && forkingRef.current !== null) {
|
|
693
|
+
const { draft } = loadForFork.serverData;
|
|
694
|
+
if (draft.id === forkingRef.current) {
|
|
695
|
+
forkingRef.current = null;
|
|
696
|
+
fork.start!({ name: entity.name, page: draft.doc, kind });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}, [loadForFork]);
|
|
700
|
+
|
|
701
|
+
// the page is gone with its whole history: whatever canvas showed a piece of it goes vacant;
|
|
702
|
+
// the listing refetches itself (the action invalidates 'pages') and the row leaves with it
|
|
703
|
+
useEffect(() => {
|
|
704
|
+
if (remove.isSuccess() && remove.serverData.ok) {
|
|
705
|
+
setConfirmingDelete(false);
|
|
706
|
+
opener.closeDocsOf(entity.name);
|
|
707
|
+
notices?.show('delete', `Deleted the ${noun} ${pattern} with every revision and draft`);
|
|
708
|
+
}
|
|
709
|
+
}, [remove]);
|
|
710
|
+
// a discarded draft is gone; a published one became a revision — the canvases holding it let go
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
if (!retiringRef.current || !discard.isSuccess() || !discard.serverData.ok) return;
|
|
713
|
+
const gone = entity.drafts.find((draft) => draft.id === retiringRef.current);
|
|
714
|
+
opener.retireDraft(entity.name, retiringRef.current);
|
|
715
|
+
retiringRef.current = null;
|
|
716
|
+
notices?.show('delete', `Discarded the draft ${gone ? draftLabel(gone) : ''} of ${pattern}`.replace(' ', ' '));
|
|
717
|
+
}, [discard]);
|
|
718
|
+
useEffect(() => {
|
|
719
|
+
if (!retiringRef.current || !publish.isSuccess() || !publish.serverData.ok) return;
|
|
720
|
+
opener.retireDraft(entity.name, retiringRef.current, publish.serverData.live);
|
|
721
|
+
retiringRef.current = null;
|
|
722
|
+
setPublishingDraft(null);
|
|
723
|
+
}, [publish]);
|
|
724
|
+
|
|
725
|
+
const { revisions, drafts } = versionsOf(entity);
|
|
726
|
+
const live = entity.meta.live ?? null;
|
|
727
|
+
// a FROZEN page belongs to the platform (the workbench): it is opened and read, never changed
|
|
728
|
+
const locked = entity.meta.locked === true;
|
|
729
|
+
// the folded row names the NEWEST drafts first — the ones most likely to be worked on
|
|
730
|
+
const tagDrafts = [...drafts].reverse();
|
|
731
|
+
const tags = useFittingTags(tagDrafts.map(draftLabel));
|
|
732
|
+
// the row's element: a STABLE ref callback — a fresh one per render would be called with null
|
|
733
|
+
// and the element again on every commit, and the state inside it would never settle
|
|
734
|
+
const [itemEl, setItemEl] = useState<HTMLLIElement | null>(null);
|
|
735
|
+
const itemRef = useCallback((el: HTMLLIElement | null) => {
|
|
736
|
+
registerEl(entity.name, el);
|
|
737
|
+
setItemEl(el);
|
|
738
|
+
}, [entity.name]);
|
|
739
|
+
|
|
740
|
+
/** the page itself: its live revision, or its newest draft while nothing is published */
|
|
741
|
+
const openPage = (): void => {
|
|
742
|
+
if (live !== null) opener.openRevision(entity.name, live);
|
|
743
|
+
else if (tagDrafts.length > 0) opener.openDraft(entity.name, tagDrafts[0].id);
|
|
744
|
+
else onExpand();
|
|
745
|
+
};
|
|
746
|
+
/** EDIT: the newest draft when there is one, else the live revision (read-only) */
|
|
747
|
+
const editPage = (): void => {
|
|
748
|
+
if (tagDrafts.length > 0) opener.openDraft(entity.name, tagDrafts[0].id);
|
|
749
|
+
else if (live !== null) opener.openRevision(entity.name, live);
|
|
750
|
+
};
|
|
751
|
+
// the tree: a click on the row unfolds its versions (the chevron's job too); the compact list's
|
|
752
|
+
// row opens the page — it is a picker
|
|
753
|
+
const rowAction = tree ? onToggle : openPage;
|
|
754
|
+
const rowTooltip = tree
|
|
755
|
+
? (expanded ? `Hide the versions of ${pattern}` : `Show every version of ${pattern} — its revisions and drafts`)
|
|
756
|
+
: live !== null
|
|
757
|
+
? `Open ${pattern} in a canvas — its live revision r${live}, read-only`
|
|
758
|
+
: tagDrafts.length > 0
|
|
759
|
+
? `Open ${pattern} in a canvas — its newest draft ${draftLabel(tagDrafts[0])}, for editing`
|
|
760
|
+
: `Show the versions of ${pattern}`;
|
|
761
|
+
|
|
762
|
+
const removeError = remove.isError()
|
|
763
|
+
? remove.serverError.code
|
|
764
|
+
: remove.isSuccess() && !remove.serverData.ok ? remove.serverData.code : null;
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<li className={`pages-panel__route-item${highlighted ? ' pages-panel__route-item--flash' : ''}`}
|
|
768
|
+
ref={itemRef}
|
|
769
|
+
data-test-id="pages-route"
|
|
770
|
+
data-page={entity.name}
|
|
771
|
+
data-pattern={pattern}
|
|
772
|
+
data-depth={tree ? depth : undefined}
|
|
773
|
+
data-expanded={expanded || undefined}>
|
|
774
|
+
<div className="pages-panel__route"
|
|
775
|
+
role="button"
|
|
776
|
+
tabIndex={0}
|
|
777
|
+
style={tree ? { paddingLeft: `${4 + depth * 18}px` } : undefined}
|
|
778
|
+
data-action={tree ? 'toggle-versions' : 'open-page'}
|
|
779
|
+
data-tooltip={rowTooltip}
|
|
780
|
+
onClick={rowAction}
|
|
781
|
+
onKeyDown={(e) => {
|
|
782
|
+
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
|
783
|
+
e.preventDefault();
|
|
784
|
+
rowAction();
|
|
785
|
+
}
|
|
786
|
+
}}>
|
|
787
|
+
{/* the tree: the chevron unfolds the page's VERSIONS one level down — a nesting of the
|
|
788
|
+
tree, not a button at the row's end */}
|
|
789
|
+
{tree ? (
|
|
790
|
+
<button type="button"
|
|
791
|
+
className="pages-panel__chevron"
|
|
792
|
+
data-action="toggle"
|
|
793
|
+
data-folded={expanded ? undefined : true}
|
|
794
|
+
aria-expanded={expanded}
|
|
795
|
+
aria-label={expanded ? 'Hide the versions' : 'Show the versions'}
|
|
796
|
+
data-tooltip={expanded
|
|
797
|
+
? `Hide the versions of this ${noun}`
|
|
798
|
+
: `Show every version of this ${noun} — its revisions and drafts`}
|
|
799
|
+
onClick={(e) => {
|
|
800
|
+
e.stopPropagation();
|
|
801
|
+
onToggle();
|
|
802
|
+
}}>
|
|
803
|
+
{ChevronIcon}
|
|
804
|
+
</button>
|
|
805
|
+
) : null}
|
|
806
|
+
<span className="pages-panel__path">
|
|
807
|
+
<Lit text={text}
|
|
808
|
+
indices={lit} />
|
|
809
|
+
</span>
|
|
810
|
+
{expanded ? null : (
|
|
811
|
+
<div className="pages-panel__tags"
|
|
812
|
+
ref={tags.ref}
|
|
813
|
+
data-test-id="pages-route-tags">
|
|
814
|
+
{locked ? (
|
|
815
|
+
<span className="pages-panel__tag pages-panel__tag--locked"
|
|
816
|
+
data-tag-kind="locked"
|
|
817
|
+
data-tooltip={LOCKED_TOOLTIP}>
|
|
818
|
+
{LockIcon}
|
|
819
|
+
</span>
|
|
820
|
+
) : null}
|
|
821
|
+
{live !== null ? (
|
|
822
|
+
<button type="button"
|
|
823
|
+
className="pages-panel__tag pages-panel__tag--published"
|
|
824
|
+
data-tag-kind="live"
|
|
825
|
+
data-rev={live}
|
|
826
|
+
data-tooltip={`Open r${live}, the live revision, in a canvas — read-only`}
|
|
827
|
+
onClick={(e) => {
|
|
828
|
+
e.stopPropagation();
|
|
829
|
+
opener.openRevision(entity.name, live);
|
|
830
|
+
}}>
|
|
831
|
+
r{live}
|
|
832
|
+
</button>
|
|
833
|
+
) : null}
|
|
834
|
+
{tagDrafts.map((draft, i) => (
|
|
835
|
+
<button key={draft.id}
|
|
836
|
+
type="button"
|
|
837
|
+
className={`pages-panel__tag pages-panel__tag--draft${i < tags.shown ? '' : ' pages-panel__tag--unfit'}`}
|
|
838
|
+
data-tag-kind="draft"
|
|
839
|
+
data-draft-id={draft.id}
|
|
840
|
+
tabIndex={i < tags.shown ? 0 : -1}
|
|
841
|
+
aria-hidden={i < tags.shown ? undefined : true}
|
|
842
|
+
data-tooltip={`Open the draft ${draftLabel(draft)} in a canvas, for editing`}
|
|
843
|
+
onClick={(e) => {
|
|
844
|
+
e.stopPropagation();
|
|
845
|
+
opener.openDraft(entity.name, draft.id);
|
|
846
|
+
}}>
|
|
847
|
+
{draftLabel(draft)}
|
|
848
|
+
</button>
|
|
849
|
+
))}
|
|
850
|
+
{tagDrafts.length > 0 ? (
|
|
851
|
+
<button type="button"
|
|
852
|
+
className={`pages-panel__tag pages-panel__tag--more${tags.shown < tagDrafts.length ? '' : ' pages-panel__tag--unfit'}`}
|
|
853
|
+
data-tag-kind="more"
|
|
854
|
+
tabIndex={tags.shown < tagDrafts.length ? 0 : -1}
|
|
855
|
+
aria-hidden={tags.shown < tagDrafts.length ? undefined : true}
|
|
856
|
+
data-tooltip={`Show every version — ${tagDrafts.length - tags.shown} more draft(s) do not fit here`}
|
|
857
|
+
onClick={(e) => {
|
|
858
|
+
e.stopPropagation();
|
|
859
|
+
onExpand();
|
|
860
|
+
}}>
|
|
861
|
+
…
|
|
862
|
+
</button>
|
|
863
|
+
) : null}
|
|
864
|
+
</div>
|
|
865
|
+
)}
|
|
866
|
+
{tree ? (
|
|
867
|
+
<span className="pages-panel__route-actions"
|
|
868
|
+
data-test-id="pages-route-actions">
|
|
869
|
+
{live !== null || tagDrafts.length > 0 ? (
|
|
870
|
+
<RouteButton testId="pages-route-edit"
|
|
871
|
+
action="edit"
|
|
872
|
+
tooltip={tagDrafts.length > 0
|
|
873
|
+
? `Open the newest draft, ${draftLabel(tagDrafts[0])}, in a canvas for editing`
|
|
874
|
+
: `Open r${live}, the live revision, in a canvas — read-only (make a new draft to change it)`}
|
|
875
|
+
disabled={busy}
|
|
876
|
+
icon={EditIcon}
|
|
877
|
+
onClick={editPage}>
|
|
878
|
+
Edit
|
|
879
|
+
</RouteButton>
|
|
880
|
+
) : null}
|
|
881
|
+
{live !== null && isConcreteRoute(pattern) && kind === 'pages' ? (
|
|
882
|
+
<RouteButton testId="pages-route-open"
|
|
883
|
+
action="open-live"
|
|
884
|
+
tooltip={`Open the live page ${pattern} in a new tab, as a reader sees it`}
|
|
885
|
+
href={pattern}
|
|
886
|
+
icon={OpenOutIcon}>
|
|
887
|
+
Open
|
|
888
|
+
</RouteButton>
|
|
889
|
+
) : null}
|
|
890
|
+
{live !== null && !locked ? (
|
|
891
|
+
<RouteButton testId="pages-route-new-draft"
|
|
892
|
+
action="duplicate"
|
|
893
|
+
tooltip={`New draft from r${live}, the live revision — it opens in the editor`}
|
|
894
|
+
disabled={busy}
|
|
895
|
+
icon={NewDraftIcon}
|
|
896
|
+
onClick={() => createDraft.start!({ name: entity.name, fromRev: live, kind })}>
|
|
897
|
+
New draft
|
|
898
|
+
</RouteButton>
|
|
899
|
+
) : null}
|
|
900
|
+
{locked ? null : (
|
|
901
|
+
<RouteButton testId="pages-route-delete"
|
|
902
|
+
action="delete-page"
|
|
903
|
+
tooltip={`Delete the whole ${noun} with every revision and draft — asks to confirm first`}
|
|
904
|
+
disabled={busy}
|
|
905
|
+
danger
|
|
906
|
+
icon={DeleteIcon}
|
|
907
|
+
onClick={() => setConfirmingDelete(true)}>
|
|
908
|
+
Delete
|
|
909
|
+
</RouteButton>
|
|
910
|
+
)}
|
|
911
|
+
</span>
|
|
912
|
+
) : null}
|
|
913
|
+
{tree ? null : (
|
|
914
|
+
<button type="button"
|
|
915
|
+
className={`pages-panel__versions${expanded ? ' pages-panel__versions--open' : ''}`}
|
|
916
|
+
data-action="toggle"
|
|
917
|
+
aria-expanded={expanded}
|
|
918
|
+
aria-label={expanded ? 'Hide versions' : 'Show versions'}
|
|
919
|
+
data-tooltip={expanded
|
|
920
|
+
? `Hide the versions of this ${noun}`
|
|
921
|
+
: `Show every version of this ${noun} — its revisions and drafts`}
|
|
922
|
+
onClick={(e) => {
|
|
923
|
+
e.stopPropagation();
|
|
924
|
+
onToggle();
|
|
925
|
+
}}>
|
|
926
|
+
{VersionsIcon}
|
|
927
|
+
</button>
|
|
928
|
+
)}
|
|
929
|
+
</div>
|
|
930
|
+
{expanded ? (
|
|
931
|
+
<div className={`pages-panel__expander${tree ? ' pages-panel__expander--nested' : ''}`}
|
|
932
|
+
style={tree ? {
|
|
933
|
+
// the rail under the centre of the next level's chevron; the rows at that level's words
|
|
934
|
+
marginLeft: `calc(${4 + (depth + 1) * 18}px + var(--mosetta-wb-icon-box) / 2)`,
|
|
935
|
+
paddingLeft: 'calc(var(--mosetta-wb-icon-box) / 2 + 4px)',
|
|
936
|
+
} : undefined}
|
|
937
|
+
data-test-id="pages-expander">
|
|
938
|
+
{revisions.map(({ rev, status }) => (
|
|
939
|
+
<div key={`r${rev}`}
|
|
940
|
+
className={`pages-panel__row pages-panel__publish-row pages-panel__publish-row--${status}`}
|
|
941
|
+
data-test-id="pages-publish-row"
|
|
942
|
+
data-rev={rev}
|
|
943
|
+
data-status={status}>
|
|
944
|
+
<button type="button"
|
|
945
|
+
className="pages-panel__row-open"
|
|
946
|
+
data-action="open"
|
|
947
|
+
data-tooltip={status === 'published'
|
|
948
|
+
? `Open r${rev}, the live revision, in a canvas — read-only (make a new draft from it to change it)`
|
|
949
|
+
: `Open r${rev}, an earlier revision, in a canvas — read-only (make a new draft from it to bring it back)`}
|
|
950
|
+
onClick={() => opener.openRevision(entity.name, rev)}>
|
|
951
|
+
<span className={`pages-panel__tag pages-panel__tag--${status}`}>r{rev}</span>
|
|
952
|
+
{entity.meta.names?.[String(rev)] ? (
|
|
953
|
+
<span className="pages-panel__row-name"
|
|
954
|
+
data-test-id="pages-revision-name">{entity.meta.names[String(rev)]}</span>
|
|
955
|
+
) : null}
|
|
956
|
+
<span className="pages-panel__row-note">{status}</span>
|
|
957
|
+
</button>
|
|
958
|
+
<span className="pages-panel__row-actions"
|
|
959
|
+
data-test-id="pages-row-actions">
|
|
960
|
+
{locked || !tree ? null : (
|
|
961
|
+
<RouteButton testId="pages-row-actions-duplicate"
|
|
962
|
+
action="duplicate"
|
|
963
|
+
tooltip={`Clone r${rev} into a new draft — it opens in a canvas for editing`}
|
|
964
|
+
disabled={busy}
|
|
965
|
+
icon={NewDraftIcon}
|
|
966
|
+
onClick={() => createDraft.start!({ name: entity.name, fromRev: rev, kind })}>
|
|
967
|
+
Clone
|
|
968
|
+
</RouteButton>
|
|
969
|
+
)}
|
|
970
|
+
{tree ? (
|
|
971
|
+
<RouteButton testId="pages-row-actions-open-new"
|
|
972
|
+
action="open-new"
|
|
973
|
+
tooltip={`Open r${rev} in a new tab, as a preview`}
|
|
974
|
+
href={previewHrefOf(kind, pattern, entity.name, `r${rev}`)}
|
|
975
|
+
icon={OpenOutIcon}>
|
|
976
|
+
Open
|
|
977
|
+
</RouteButton>
|
|
978
|
+
) : null}
|
|
979
|
+
{locked || tree ? null : (
|
|
980
|
+
<ActionIcon testId="pages-row-actions-duplicate"
|
|
981
|
+
action="duplicate"
|
|
982
|
+
label={`New draft from r${rev}`}
|
|
983
|
+
tooltip={`New draft from r${rev} — it opens in a canvas for editing`}
|
|
984
|
+
disabled={busy}
|
|
985
|
+
onClick={() => createDraft.start!({ name: entity.name, fromRev: rev, kind })}>
|
|
986
|
+
{NewDraftIcon}
|
|
987
|
+
</ActionIcon>
|
|
988
|
+
)}
|
|
989
|
+
</span>
|
|
990
|
+
</div>
|
|
991
|
+
))}
|
|
992
|
+
{drafts.map((draft) => (
|
|
993
|
+
<div key={draft.id}
|
|
994
|
+
className="pages-panel__row pages-panel__draft-row"
|
|
995
|
+
data-test-id="pages-draft-row"
|
|
996
|
+
data-draft-id={draft.id}
|
|
997
|
+
data-base-rev={draft.baseRev}>
|
|
998
|
+
<button type="button"
|
|
999
|
+
className="pages-panel__row-open"
|
|
1000
|
+
data-action="open"
|
|
1001
|
+
data-tooltip={`Open the draft ${draftLabel(draft)} in a canvas, for editing`}
|
|
1002
|
+
onClick={() => opener.openDraft(entity.name, draft.id)}>
|
|
1003
|
+
<span className="pages-panel__tag pages-panel__tag--draft">{draftLabel(draft)}</span>
|
|
1004
|
+
{draft.baseRev != null ? <span className="pages-panel__row-note">from r{draft.baseRev}</span> : null}
|
|
1005
|
+
{draft.pattern !== null && draft.pattern !== pattern ? (
|
|
1006
|
+
<span className="pages-panel__row-note pages-panel__row-note--moved"
|
|
1007
|
+
data-tooltip={`Publishing this draft moves the page to ${draft.pattern}`}>
|
|
1008
|
+
→ {draft.pattern}
|
|
1009
|
+
</span>
|
|
1010
|
+
) : null}
|
|
1011
|
+
</button>
|
|
1012
|
+
<span className="pages-panel__row-actions"
|
|
1013
|
+
data-test-id="pages-row-actions">
|
|
1014
|
+
{tree ? (
|
|
1015
|
+
<>
|
|
1016
|
+
<RouteButton testId="pages-row-actions-duplicate"
|
|
1017
|
+
action="duplicate"
|
|
1018
|
+
tooltip="Clone this draft — a copy appears in this list"
|
|
1019
|
+
disabled={busy}
|
|
1020
|
+
icon={NewDraftIcon}
|
|
1021
|
+
onClick={() => {
|
|
1022
|
+
forkingRef.current = draft.id;
|
|
1023
|
+
loadForFork.start!({ name: entity.name, draftId: draft.id, kind });
|
|
1024
|
+
}}>
|
|
1025
|
+
Clone
|
|
1026
|
+
</RouteButton>
|
|
1027
|
+
<RouteButton testId="pages-row-actions-open-new"
|
|
1028
|
+
action="open-new"
|
|
1029
|
+
tooltip={`Open the draft ${draftLabel(draft)} in a new tab, as a preview`}
|
|
1030
|
+
href={previewHrefOf(kind, pattern, entity.name, draft.id)}
|
|
1031
|
+
icon={OpenOutIcon}>
|
|
1032
|
+
Open
|
|
1033
|
+
</RouteButton>
|
|
1034
|
+
</>
|
|
1035
|
+
) : (
|
|
1036
|
+
<ActionIcon testId="pages-row-actions-duplicate"
|
|
1037
|
+
action="duplicate"
|
|
1038
|
+
label="New draft from this draft"
|
|
1039
|
+
tooltip="New draft from this draft — a copy appears in this list"
|
|
1040
|
+
disabled={busy}
|
|
1041
|
+
onClick={() => {
|
|
1042
|
+
forkingRef.current = draft.id;
|
|
1043
|
+
loadForFork.start!({ name: entity.name, draftId: draft.id, kind });
|
|
1044
|
+
}}>
|
|
1045
|
+
{NewDraftIcon}
|
|
1046
|
+
</ActionIcon>
|
|
1047
|
+
)}
|
|
1048
|
+
<ActionIcon testId="pages-row-actions-publish"
|
|
1049
|
+
action="publish"
|
|
1050
|
+
label="Publish this draft"
|
|
1051
|
+
tooltip={live !== null
|
|
1052
|
+
? `Publish this draft — it becomes the live revision in place of r${live}`
|
|
1053
|
+
: 'Publish this draft — it becomes the live revision'}
|
|
1054
|
+
disabled={busy}
|
|
1055
|
+
onClick={() => setPublishingDraft(draft)}>
|
|
1056
|
+
{PublishIcon}
|
|
1057
|
+
</ActionIcon>
|
|
1058
|
+
<ActionIcon testId="pages-row-actions-delete"
|
|
1059
|
+
action="delete"
|
|
1060
|
+
label="Discard this draft"
|
|
1061
|
+
tooltip={deleteArmed === draft.id
|
|
1062
|
+
? 'Click again to discard this draft for good'
|
|
1063
|
+
: 'Discard this draft — a second click confirms'}
|
|
1064
|
+
disabled={busy}
|
|
1065
|
+
onClick={() => {
|
|
1066
|
+
if (deleteArmed === draft.id) {
|
|
1067
|
+
setDeleteArmed(null);
|
|
1068
|
+
retiringRef.current = draft.id;
|
|
1069
|
+
discard.start!({ name: entity.name, draftId: draft.id, kind });
|
|
1070
|
+
} else {
|
|
1071
|
+
setDeleteArmed(draft.id);
|
|
1072
|
+
}
|
|
1073
|
+
}}>
|
|
1074
|
+
{deleteArmed === draft.id
|
|
1075
|
+
? <span className="pages-panel__confirm"
|
|
1076
|
+
aria-hidden="true">?</span>
|
|
1077
|
+
: DeleteIcon}
|
|
1078
|
+
</ActionIcon>
|
|
1079
|
+
</span>
|
|
1080
|
+
</div>
|
|
1081
|
+
))}
|
|
1082
|
+
{tree && !locked ? null : (
|
|
1083
|
+
<div className="pages-panel__row pages-panel__page-row"
|
|
1084
|
+
data-test-id="pages-page-row">
|
|
1085
|
+
<span className="pages-panel__row-label">
|
|
1086
|
+
{locked
|
|
1087
|
+
? <span className="pages-panel__row-note"
|
|
1088
|
+
data-tooltip={LOCKED_TOOLTIP}>the platform's page — updates with the platform</span>
|
|
1089
|
+
: <span className="pages-panel__row-note">whole {noun}</span>}
|
|
1090
|
+
</span>
|
|
1091
|
+
{locked ? null : (
|
|
1092
|
+
<span className="pages-panel__row-actions"
|
|
1093
|
+
data-test-id="pages-row-actions">
|
|
1094
|
+
<ActionIcon testId="pages-row-actions-delete-page"
|
|
1095
|
+
action="delete-page"
|
|
1096
|
+
label={`Delete this ${noun}`}
|
|
1097
|
+
tooltip={`Delete the whole ${noun} with every revision and draft — asks to confirm first`}
|
|
1098
|
+
disabled={busy}
|
|
1099
|
+
onClick={() => setConfirmingDelete(true)}>
|
|
1100
|
+
{DeleteIcon}
|
|
1101
|
+
</ActionIcon>
|
|
1102
|
+
</span>
|
|
1103
|
+
)}
|
|
1104
|
+
</div>
|
|
1105
|
+
)}
|
|
1106
|
+
{publish.isError() && <div className="pages-panel__error">{publish.serverError.code}</div>}
|
|
1107
|
+
{publish.isSuccess() && !publish.serverData.ok && (
|
|
1108
|
+
<div className="pages-panel__error"
|
|
1109
|
+
data-publish-error={publish.serverData.code}>
|
|
1110
|
+
{publish.serverData.code}
|
|
1111
|
+
</div>
|
|
1112
|
+
)}
|
|
1113
|
+
</div>
|
|
1114
|
+
) : null}
|
|
1115
|
+
{publishingDraft && itemEl ? (
|
|
1116
|
+
<PublishModal host={itemEl.ownerDocument}
|
|
1117
|
+
noun={noun}
|
|
1118
|
+
route={pattern}
|
|
1119
|
+
suggested={publishingDraft.title ?? publishingDraft.id.slice(0, 8)}
|
|
1120
|
+
live={live}
|
|
1121
|
+
publishing={publish.isLoading()}
|
|
1122
|
+
error={publish.isError()
|
|
1123
|
+
? publish.serverError.code
|
|
1124
|
+
: publish.isSuccess() && !publish.serverData.ok && retiringRef.current === null
|
|
1125
|
+
? [publish.serverData.code, ...(publish.serverData.issues ?? [])].join('\n')
|
|
1126
|
+
: null}
|
|
1127
|
+
onCancel={() => setPublishingDraft(null)}
|
|
1128
|
+
onConfirm={(title) => {
|
|
1129
|
+
retiringRef.current = publishingDraft.id;
|
|
1130
|
+
publish.start!({ name: entity.name, draftId: publishingDraft.id, kind, title });
|
|
1131
|
+
}} />
|
|
1132
|
+
) : null}
|
|
1133
|
+
{confirmingDelete && itemEl ? (
|
|
1134
|
+
<DeletePageModal host={itemEl.ownerDocument}
|
|
1135
|
+
route={route}
|
|
1136
|
+
deleting={remove.isLoading()}
|
|
1137
|
+
error={removeError}
|
|
1138
|
+
onCancel={() => setConfirmingDelete(false)}
|
|
1139
|
+
onConfirm={() => remove.start!({ name: entity.name, kind })} />
|
|
1140
|
+
) : null}
|
|
1141
|
+
</li>
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// ---- the create modal --------------------------------------------------------------------------
|
|
1146
|
+
|
|
1147
|
+
interface ICreateModalProps {
|
|
1148
|
+
readonly routes: readonly IRouteVm[];
|
|
1149
|
+
readonly own: ITileReference;
|
|
1150
|
+
readonly onClose: () => void;
|
|
1151
|
+
/** the typed path already exists — the panel reveals it */
|
|
1152
|
+
readonly onJump: (name: string) => void;
|
|
1153
|
+
/** a page was created: reveal + open its first draft */
|
|
1154
|
+
readonly onCreated: (name: string, draftId: string) => void;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** a route pattern the create input accepts: absolute, no spaces (parameters like {id} are fine) */
|
|
1158
|
+
const CREATABLE_PATTERN_RE = /^\/\S*$/;
|
|
1159
|
+
/** what a path can never hold: whitespace, and `#` (a fragment never reaches a route) */
|
|
1160
|
+
const PATH_REFUSED_RE = /[\s#]/;
|
|
1161
|
+
/** what a name can never hold: anything but letters and digits (ENTITY_NAME_RE) */
|
|
1162
|
+
const NAME_REFUSED_RE = /[^A-Za-z0-9]/;
|
|
1163
|
+
|
|
1164
|
+
function CreateModal({ routes, own, onClose, onJump, onCreated }: ICreateModalProps) {
|
|
1165
|
+
const { kind, noun } = useContext(EntityWords);
|
|
1166
|
+
// a page is created at a PATH (its name derived from it); a prefab by its NAME
|
|
1167
|
+
const byName = kind === 'prefabs';
|
|
1168
|
+
const [value, setValue] = useState(byName ? '' : '/');
|
|
1169
|
+
const components = useServerAction({ token: listComponentsToken, input: {} });
|
|
1170
|
+
const create = useServerAction({ token: createPageToken, start: EServerActionStart.START_MANUAL });
|
|
1171
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
1172
|
+
const createdRef = useRef<string | null>(null);
|
|
1173
|
+
useEffect(() => inputRef.current?.focus(), []);
|
|
1174
|
+
const notices = noticesOf(useDi(DI_CONTAINER_TOKEN));
|
|
1175
|
+
// letters the field cannot carry never get in: typed ones are refused in silence, a PASTE with
|
|
1176
|
+
// them is refused with a word — the author is told why nothing happened
|
|
1177
|
+
const refuse = (e: React.FormEvent<HTMLInputElement>): void => {
|
|
1178
|
+
const native = e.nativeEvent as InputEvent;
|
|
1179
|
+
const data = native.data;
|
|
1180
|
+
if (data == null || !(byName ? NAME_REFUSED_RE : PATH_REFUSED_RE).test(data)) return;
|
|
1181
|
+
e.preventDefault();
|
|
1182
|
+
if (native.inputType === 'insertFromPaste') {
|
|
1183
|
+
notices?.show('refuse', byName
|
|
1184
|
+
? 'That cannot be a prefab name: letters and digits only'
|
|
1185
|
+
: 'That cannot be a page path: no spaces, no #');
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
const trimmed = value.trim();
|
|
1190
|
+
const existing = useMemo(() => rankPaths(routes, trimmed, (route) => route.pattern), [routes, trimmed]);
|
|
1191
|
+
const exact = routes.find((route) => route.pattern === trimmed) ?? null;
|
|
1192
|
+
const creatable = !exact && (byName ? ENTITY_NAME_RE.test(trimmed) : CREATABLE_PATTERN_RE.test(trimmed));
|
|
1193
|
+
// the NAME the entity gets — its address in the workbench — said as the path is typed
|
|
1194
|
+
const taken = useMemo(() => new Set(routes.map((route) => route.entity.name)), [routes]);
|
|
1195
|
+
const name = creatable ? (byName ? trimmed : nameFromPattern(trimmed, taken)) : null;
|
|
1196
|
+
|
|
1197
|
+
// the ROOT the new page starts with: the project's own page root when its catalog has one,
|
|
1198
|
+
// else the editor's generic root. Not asked here — what a root component is makes sense with
|
|
1199
|
+
// the page in front of one; it is changed in the page's settings
|
|
1200
|
+
const rootId = useMemo(() => {
|
|
1201
|
+
const listed = components.isSuccess() && components.serverData.ok
|
|
1202
|
+
? components.serverData.components.filter((c) => c.isRoot).map((c) => c.id)
|
|
1203
|
+
: [];
|
|
1204
|
+
const fallback = `_ns_${own.namespace}_t_component_n_page_v_${own.version}`;
|
|
1205
|
+
return listed.find((id) => !id.startsWith(`_ns_${own.namespace}_`)) ?? listed[0] ?? fallback;
|
|
1206
|
+
}, [components, own.namespace, own.version]);
|
|
1207
|
+
|
|
1208
|
+
useEffect(() => {
|
|
1209
|
+
if (create.isSuccess() && create.serverData.ok && createdRef.current !== null) {
|
|
1210
|
+
const created = createdRef.current;
|
|
1211
|
+
createdRef.current = null;
|
|
1212
|
+
onCreated(created, create.serverData.draftId);
|
|
1213
|
+
}
|
|
1214
|
+
}, [create]);
|
|
1215
|
+
|
|
1216
|
+
const submit = (): void => {
|
|
1217
|
+
if (!name || create.isLoading()) return;
|
|
1218
|
+
createdRef.current = name;
|
|
1219
|
+
create.start!({ name, page: { id: name, reference: rootId, pattern: byName ? PREFAB_CANVAS_PATTERN : trimmed, children: [] }, kind });
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
return (
|
|
1223
|
+
<div className="pages-panel__modal-overlay"
|
|
1224
|
+
data-test-id="pages-create-modal"
|
|
1225
|
+
onClick={onClose}>
|
|
1226
|
+
<div className="pages-panel__modal"
|
|
1227
|
+
role="dialog"
|
|
1228
|
+
aria-label={`Create a ${noun}`}
|
|
1229
|
+
onClick={(e) => e.stopPropagation()}>
|
|
1230
|
+
<div className="pages-panel__modal-title">Create a {noun}</div>
|
|
1231
|
+
<input ref={inputRef}
|
|
1232
|
+
className="pages-panel__modal-input"
|
|
1233
|
+
data-test-id="pages-create-input"
|
|
1234
|
+
type="text"
|
|
1235
|
+
placeholder={byName ? 'nameOfTheNewPrefab' : '/path/of/the/new/page'}
|
|
1236
|
+
value={value}
|
|
1237
|
+
spellCheck={false}
|
|
1238
|
+
onBeforeInput={refuse}
|
|
1239
|
+
onChange={(e) => setValue(e.target.value)}
|
|
1240
|
+
onKeyDown={(e) => {
|
|
1241
|
+
if (e.key === 'Escape') onClose();
|
|
1242
|
+
if (e.key === 'Enter') submit();
|
|
1243
|
+
}} />
|
|
1244
|
+
{exact ? (
|
|
1245
|
+
<button type="button"
|
|
1246
|
+
className="pages-panel__modal-hint"
|
|
1247
|
+
data-test-id="pages-create-exists"
|
|
1248
|
+
onClick={() => onJump(exact.entity.name)}>
|
|
1249
|
+
{exact.pattern} already exists — go to it
|
|
1250
|
+
</button>
|
|
1251
|
+
) : null}
|
|
1252
|
+
{!exact && existing.length > 0 ? (
|
|
1253
|
+
<ul className="pages-panel__modal-list"
|
|
1254
|
+
data-test-id="pages-create-suggestions">
|
|
1255
|
+
{existing.slice(0, 6).map(({ item, match }) => (
|
|
1256
|
+
<li key={item.entity.name}>
|
|
1257
|
+
<button type="button"
|
|
1258
|
+
className="pages-panel__modal-item"
|
|
1259
|
+
data-page={item.entity.name}
|
|
1260
|
+
onClick={() => onJump(item.entity.name)}>
|
|
1261
|
+
<HighlightedPath path={item.pattern}
|
|
1262
|
+
match={match} />
|
|
1263
|
+
</button>
|
|
1264
|
+
</li>
|
|
1265
|
+
))}
|
|
1266
|
+
</ul>
|
|
1267
|
+
) : null}
|
|
1268
|
+
{name ? (
|
|
1269
|
+
<div className="pages-panel__modal-name"
|
|
1270
|
+
data-test-id="pages-create-name"
|
|
1271
|
+
data-name={name}>
|
|
1272
|
+
{byName ? 'The prefab goes by ' : 'The page will be named '}
|
|
1273
|
+
<code>{name}</code>
|
|
1274
|
+
{byName ? ' — its address in the workbench' : ' — its address in the workbench; the route is what readers see'}
|
|
1275
|
+
</div>
|
|
1276
|
+
) : null}
|
|
1277
|
+
{byName && trimmed && !exact && !creatable ? (
|
|
1278
|
+
<div className="pages-panel__modal-hint"
|
|
1279
|
+
data-test-id="pages-create-name-hint">
|
|
1280
|
+
A prefab's name: a lowercase letter first, then letters and digits — blogFrame, buyButton
|
|
1281
|
+
</div>
|
|
1282
|
+
) : null}
|
|
1283
|
+
<button type="button"
|
|
1284
|
+
className="pages-panel__button pages-panel__button--primary pages-panel__modal-submit"
|
|
1285
|
+
data-test-id="pages-create-submit"
|
|
1286
|
+
disabled={!creatable || create.isLoading()}
|
|
1287
|
+
onClick={submit}>
|
|
1288
|
+
{create.isLoading() ? 'Creating…' : `Create the ${noun}`}
|
|
1289
|
+
</button>
|
|
1290
|
+
{create.isError() && <div className="pages-panel__error">create failed: {create.serverError.code}</div>}
|
|
1291
|
+
{create.isSuccess() && !create.serverData.ok && (
|
|
1292
|
+
<div className="pages-panel__error">create failed: {create.serverData.code}</div>
|
|
1293
|
+
)}
|
|
1294
|
+
</div>
|
|
1295
|
+
</div>
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// ---- the panel ---------------------------------------------------------------------------------
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Everything the live site kept — a page's render, a router's verdict, the route table — is read
|
|
1303
|
+
* anew: the remedy for a change made to the store by hand, which no publish announced. Says what
|
|
1304
|
+
* happened for a moment; a store that keeps nothing (dev) says so.
|
|
1305
|
+
*/
|
|
1306
|
+
function RefreshSiteButton() {
|
|
1307
|
+
const refresh = useServerAction({ token: refreshLiveSiteToken, start: EServerActionStart.START_MANUAL });
|
|
1308
|
+
const [said, setSaid] = useState<string | null>(null);
|
|
1309
|
+
useEffect(() => {
|
|
1310
|
+
if (!refresh.isSuccess() && !refresh.isError()) return undefined;
|
|
1311
|
+
const ok = refresh.isSuccess() && refresh.serverData.ok;
|
|
1312
|
+
setSaid(!ok ? 'Could not refresh' : refresh.serverData.refreshed ? 'The live site reads anew' : 'Nothing is kept here');
|
|
1313
|
+
const timer = setTimeout(() => setSaid(null), 2500);
|
|
1314
|
+
return () => clearTimeout(timer);
|
|
1315
|
+
}, [refresh]);
|
|
1316
|
+
return (
|
|
1317
|
+
<>
|
|
1318
|
+
{said ? <span className="pages-panel__working"
|
|
1319
|
+
data-test-id="pages-panel-refreshed">{said}</span> : null}
|
|
1320
|
+
<button type="button"
|
|
1321
|
+
className="pages-panel__create"
|
|
1322
|
+
data-test-id="pages-panel-refresh-site"
|
|
1323
|
+
disabled={refresh.isLoading()}
|
|
1324
|
+
data-tooltip="Refresh the live site: what readers get is read anew from what is stored — after a change made to the store by hand"
|
|
1325
|
+
aria-label="Refresh the live site"
|
|
1326
|
+
onClick={() => refresh.start!({})}>
|
|
1327
|
+
<svg viewBox="0 0 16 16"
|
|
1328
|
+
aria-hidden="true">
|
|
1329
|
+
<path d="M12.6 6.2A5 5 0 1 0 13 9.4M12.8 2.8v3.6H9.2"
|
|
1330
|
+
fill="none"
|
|
1331
|
+
stroke="currentColor"
|
|
1332
|
+
strokeWidth="1.5"
|
|
1333
|
+
strokeLinecap="round"
|
|
1334
|
+
strokeLinejoin="round" />
|
|
1335
|
+
</svg>
|
|
1336
|
+
</button>
|
|
1337
|
+
</>
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** a prefix no page owns (`/docs` under `/docs/api/{name}`): a row that only folds what is under it */
|
|
1342
|
+
function FolderRow({ node, depth, folded, onFold }: { node: IRouteTreeNode<IRouteVm>; depth: number; folded: boolean; onFold: () => void }) {
|
|
1343
|
+
return (
|
|
1344
|
+
<li className="pages-panel__route-item pages-panel__route-item--folder"
|
|
1345
|
+
data-test-id="pages-folder"
|
|
1346
|
+
data-path={node.path}
|
|
1347
|
+
data-depth={depth}>
|
|
1348
|
+
<div className="pages-panel__route pages-panel__route--folder"
|
|
1349
|
+
role="button"
|
|
1350
|
+
tabIndex={0}
|
|
1351
|
+
style={{ paddingLeft: `${4 + depth * 18}px` }}
|
|
1352
|
+
data-action="fold"
|
|
1353
|
+
data-folded={folded || undefined}
|
|
1354
|
+
data-tooltip={folded ? 'Show the pages under this path' : 'Hide the pages under this path'}
|
|
1355
|
+
onClick={onFold}
|
|
1356
|
+
onKeyDown={(e) => {
|
|
1357
|
+
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
|
1358
|
+
e.preventDefault();
|
|
1359
|
+
onFold();
|
|
1360
|
+
}
|
|
1361
|
+
}}>
|
|
1362
|
+
<span className="pages-panel__chevron"
|
|
1363
|
+
data-folded={folded || undefined}
|
|
1364
|
+
aria-hidden="true">
|
|
1365
|
+
{ChevronIcon}
|
|
1366
|
+
</span>
|
|
1367
|
+
<span className="pages-panel__path">
|
|
1368
|
+
<Lit text={node.segment}
|
|
1369
|
+
indices={node.lit} />
|
|
1370
|
+
</span>
|
|
1371
|
+
</div>
|
|
1372
|
+
</li>
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** the panel's body: the listing of whichever entities the words name */
|
|
1377
|
+
function EntityPanel({ compact }: { compact: boolean }) {
|
|
1378
|
+
const words = useContext(EntityWords);
|
|
1379
|
+
const pages = useServerAction({ token: listPagesToken, input: { kind: words.kind } });
|
|
1380
|
+
const own = useComponent().reference;
|
|
1381
|
+
const di = useDi(DI_CONTAINER_TOKEN);
|
|
1382
|
+
const query = useMemo(() => ensureQueryParams(di), [di]);
|
|
1383
|
+
|
|
1384
|
+
const [term, setTerm] = useState<string>(() => query?.get(words.searchParam) ?? '');
|
|
1385
|
+
useEffect(() => {
|
|
1386
|
+
query?.set(words.searchParam, term.trim() ? term : null);
|
|
1387
|
+
}, [query, term, words.searchParam]);
|
|
1388
|
+
|
|
1389
|
+
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
|
|
1390
|
+
/** the tree's folded branches, by node path — unfolded unless folded, and a search unfolds all */
|
|
1391
|
+
const [folded, setFolded] = useState<ReadonlySet<string>>(() => new Set());
|
|
1392
|
+
const [flash, setFlash] = useState<{ name: string; stamp: number } | null>(null);
|
|
1393
|
+
const [creating, setCreating] = useState(false);
|
|
1394
|
+
const routeEls = useRef(new Map<string, HTMLLIElement>());
|
|
1395
|
+
const registerEl = (name: string, el: HTMLLIElement | null): void => {
|
|
1396
|
+
if (el) routeEls.current.set(name, el);
|
|
1397
|
+
else routeEls.current.delete(name);
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
const opener = useDocOpener();
|
|
1401
|
+
|
|
1402
|
+
// the freeze (see IPanelBusy): actions in flight, then the wait for the listing they changed
|
|
1403
|
+
const [actionsInFlight, setActionsInFlight] = useState(0);
|
|
1404
|
+
const [awaitingListing, setAwaitingListing] = useState(false);
|
|
1405
|
+
const listing = pages.isSuccess() ? pages.serverData : null;
|
|
1406
|
+
useEffect(() => setAwaitingListing(false), [listing]);
|
|
1407
|
+
useEffect(() => {
|
|
1408
|
+
if (!awaitingListing) return undefined;
|
|
1409
|
+
const timer = setTimeout(() => setAwaitingListing(false), LISTING_WAIT_MS);
|
|
1410
|
+
return () => clearTimeout(timer);
|
|
1411
|
+
}, [awaitingListing]);
|
|
1412
|
+
const busyPanel = useMemo<IPanelBusy>(() => ({
|
|
1413
|
+
begin: () => setActionsInFlight((n) => n + 1),
|
|
1414
|
+
end: (changed) => {
|
|
1415
|
+
setActionsInFlight((n) => Math.max(0, n - 1));
|
|
1416
|
+
if (changed) setAwaitingListing(true);
|
|
1417
|
+
},
|
|
1418
|
+
}), []);
|
|
1419
|
+
const frozen = actionsInFlight > 0 || awaitingListing;
|
|
1420
|
+
|
|
1421
|
+
const toggleIn = (set: (update: (prev: ReadonlySet<string>) => ReadonlySet<string>) => void) => (key: string): void => set((prev) => {
|
|
1422
|
+
const next = new Set(prev);
|
|
1423
|
+
if (next.has(key)) next.delete(key);
|
|
1424
|
+
else next.add(key);
|
|
1425
|
+
return next;
|
|
1426
|
+
});
|
|
1427
|
+
const toggle = toggleIn(setExpanded);
|
|
1428
|
+
const toggleFold = toggleIn(setFolded);
|
|
1429
|
+
|
|
1430
|
+
/** expand + flash + scroll a route into view — the "go to it" of search and creation */
|
|
1431
|
+
const reveal = (name: string): void => {
|
|
1432
|
+
setTerm('');
|
|
1433
|
+
setFolded(new Set()); // wherever it sits, the branch above it opens
|
|
1434
|
+
setExpanded((prev) => new Set(prev).add(name));
|
|
1435
|
+
setFlash({ name, stamp: Date.now() });
|
|
1436
|
+
// after the re-render that unhides the row (the search reset may have been filtering it out)
|
|
1437
|
+
setTimeout(() => routeEls.current.get(name)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), 0);
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
const routes = useMemo((): IRouteVm[] => (
|
|
1441
|
+
pages.isSuccess() && pages.serverData.ok ? pages.serverData.pages.map((entity) => routeVmOf(entity, words.kind)) : []
|
|
1442
|
+
), [pages, words.kind]);
|
|
1443
|
+
|
|
1444
|
+
// ranked (the empty term ranks everything equal → alphabetical): the compact list groups the
|
|
1445
|
+
// matches by their first path segment, keeping the rank order of each group's best entry, so a
|
|
1446
|
+
// search re-orders groups too; the tree is rebuilt from the matches alone, the lit characters
|
|
1447
|
+
// carried down its branches
|
|
1448
|
+
const ranked = useMemo(() => rankPaths(routes, term, (route) => route.pattern), [routes, term]);
|
|
1449
|
+
const searching = term.trim() !== '';
|
|
1450
|
+
const groups = useMemo(() => {
|
|
1451
|
+
const byKey = new Map<string, Array<{ item: IRouteVm; match: IPathMatch }>>();
|
|
1452
|
+
for (const entry of ranked) {
|
|
1453
|
+
// prefabs are named, not routed: one group
|
|
1454
|
+
const key = words.kind === 'prefabs' ? '' : groupKeyOf(entry.item.pattern);
|
|
1455
|
+
const bucket = byKey.get(key);
|
|
1456
|
+
if (bucket) bucket.push(entry);
|
|
1457
|
+
else byKey.set(key, [entry]);
|
|
1458
|
+
}
|
|
1459
|
+
return [...byKey.entries()];
|
|
1460
|
+
}, [ranked, words.kind]);
|
|
1461
|
+
const tree = useMemo(() => {
|
|
1462
|
+
const matches = new Map(ranked.map(({ item, match }) => [item, match]));
|
|
1463
|
+
return buildRouteTree(ranked.map(({ item }) => item), (route) => route.pattern, searching ? (route) => matches.get(route) ?? null : undefined);
|
|
1464
|
+
}, [ranked, searching]);
|
|
1465
|
+
/** the tree's rows, depth-first, folded branches left out (a search unfolds everything) */
|
|
1466
|
+
const treeRows = useMemo(() => {
|
|
1467
|
+
const rows: Array<{ node: IRouteTreeNode<IRouteVm>; depth: number }> = [];
|
|
1468
|
+
const walk = (nodes: readonly IRouteTreeNode<IRouteVm>[], depth: number): void => {
|
|
1469
|
+
for (const node of nodes) {
|
|
1470
|
+
rows.push({ node, depth });
|
|
1471
|
+
// a FOLDER folds its branch; a page's children stay in view (its chevron is for the versions)
|
|
1472
|
+
if (searching || node.route || !folded.has(node.path)) walk(node.children, depth + 1);
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
walk(tree, 0);
|
|
1476
|
+
return rows;
|
|
1477
|
+
}, [tree, folded, searching]);
|
|
1478
|
+
|
|
1479
|
+
const listed = words.title.toLowerCase();
|
|
1480
|
+
if (pages.isLoading()) return <div className="pages-panel pages-panel__loading">Loading {listed}…</div>;
|
|
1481
|
+
if (pages.isError()) {
|
|
1482
|
+
return <div className="pages-panel pages-panel__error">{listed} failed: {pages.serverError.code}</div>;
|
|
1483
|
+
}
|
|
1484
|
+
if (!pages.isSuccess()) return null;
|
|
1485
|
+
const result = pages.serverData;
|
|
1486
|
+
if (!result.ok) return <div className="pages-panel pages-panel__error">{listed} failed: {result.code}</div>;
|
|
1487
|
+
|
|
1488
|
+
return (
|
|
1489
|
+
<TooltipHost>
|
|
1490
|
+
<div className={`pages-panel${frozen ? ' pages-panel--frozen' : ''}${compact ? ' pages-panel--compact' : ' pages-panel--tree'}`}
|
|
1491
|
+
data-test-id="pages-panel"
|
|
1492
|
+
data-kind={words.kind}
|
|
1493
|
+
data-layout={compact ? 'compact' : 'tree'}
|
|
1494
|
+
data-frozen={frozen || undefined}
|
|
1495
|
+
aria-busy={frozen || undefined}>
|
|
1496
|
+
<div className="pages-panel__bar">
|
|
1497
|
+
<SearchField>
|
|
1498
|
+
<input className="pages-panel__search"
|
|
1499
|
+
data-test-id="pages-search-bar"
|
|
1500
|
+
type="search"
|
|
1501
|
+
placeholder={words.kind === 'prefabs' ? 'Search prefabs by name' : 'Search routes — /path, initials (ags), anything'}
|
|
1502
|
+
value={term}
|
|
1503
|
+
spellCheck={false}
|
|
1504
|
+
onChange={(e) => setTerm(e.target.value)} />
|
|
1505
|
+
</SearchField>
|
|
1506
|
+
{frozen ? <span className="pages-panel__working"
|
|
1507
|
+
data-test-id="pages-panel-working">Working…</span> : null}
|
|
1508
|
+
{words.kind === 'pages' ? <RefreshSiteButton /> : null}
|
|
1509
|
+
<button type="button"
|
|
1510
|
+
className="pages-panel__create"
|
|
1511
|
+
data-test-id="pages-panel-create"
|
|
1512
|
+
disabled={frozen}
|
|
1513
|
+
data-tooltip={words.kind === 'prefabs' ? 'Create a prefab: blocks to place on many pages, kept in one place' : 'Create a page at a new path'}
|
|
1514
|
+
aria-label={`Create a ${words.noun}`}
|
|
1515
|
+
onClick={() => setCreating(true)}>
|
|
1516
|
+
<svg viewBox="0 0 16 16"
|
|
1517
|
+
aria-hidden="true">
|
|
1518
|
+
<path d="M8 3.2v9.6M3.2 8h9.6"
|
|
1519
|
+
fill="none"
|
|
1520
|
+
stroke="currentColor"
|
|
1521
|
+
strokeWidth="1.7" />
|
|
1522
|
+
</svg>
|
|
1523
|
+
</button>
|
|
1524
|
+
</div>
|
|
1525
|
+
{ranked.length === 0 ? (
|
|
1526
|
+
<div className="pages-panel__empty">{searching ? 'Nothing matches' : `No ${listed} yet`}</div>
|
|
1527
|
+
) : compact ? (
|
|
1528
|
+
<div className="pages-panel__groups">
|
|
1529
|
+
{groups.map(([key, entries]) => (
|
|
1530
|
+
<ul key={key}
|
|
1531
|
+
className="pages-panel__group"
|
|
1532
|
+
data-group={key}>
|
|
1533
|
+
{entries.map(({ item, match }) => (
|
|
1534
|
+
<RouteItem key={item.entity.name}
|
|
1535
|
+
busyPanel={busyPanel}
|
|
1536
|
+
route={item}
|
|
1537
|
+
text={item.pattern}
|
|
1538
|
+
lit={searching ? match.indices : []}
|
|
1539
|
+
tree={false}
|
|
1540
|
+
depth={0}
|
|
1541
|
+
expanded={expanded.has(item.entity.name)}
|
|
1542
|
+
highlighted={flash?.name === item.entity.name}
|
|
1543
|
+
onToggle={() => toggle(item.entity.name)}
|
|
1544
|
+
onExpand={() => setExpanded((prev) => new Set(prev).add(item.entity.name))}
|
|
1545
|
+
opener={opener}
|
|
1546
|
+
registerEl={registerEl} />
|
|
1547
|
+
))}
|
|
1548
|
+
</ul>
|
|
1549
|
+
))}
|
|
1550
|
+
</div>
|
|
1551
|
+
) : (
|
|
1552
|
+
<div className="pages-panel__groups">
|
|
1553
|
+
<ul className="pages-panel__group pages-panel__tree"
|
|
1554
|
+
data-test-id="pages-tree">
|
|
1555
|
+
{treeRows.map(({ node, depth }) => (node.route ? (
|
|
1556
|
+
<RouteItem key={node.path}
|
|
1557
|
+
busyPanel={busyPanel}
|
|
1558
|
+
route={node.route}
|
|
1559
|
+
text={node.segment}
|
|
1560
|
+
lit={node.lit}
|
|
1561
|
+
tree
|
|
1562
|
+
depth={depth}
|
|
1563
|
+
expanded={expanded.has(node.route.entity.name)}
|
|
1564
|
+
highlighted={flash?.name === node.route.entity.name}
|
|
1565
|
+
onToggle={() => toggle(node.route!.entity.name)}
|
|
1566
|
+
onExpand={() => setExpanded((prev) => new Set(prev).add(node.route!.entity.name))}
|
|
1567
|
+
opener={opener}
|
|
1568
|
+
registerEl={registerEl} />
|
|
1569
|
+
) : (
|
|
1570
|
+
<FolderRow key={node.path}
|
|
1571
|
+
node={node}
|
|
1572
|
+
depth={depth}
|
|
1573
|
+
folded={!searching && folded.has(node.path)}
|
|
1574
|
+
onFold={() => toggleFold(node.path)} />
|
|
1575
|
+
)))}
|
|
1576
|
+
</ul>
|
|
1577
|
+
</div>
|
|
1578
|
+
)}
|
|
1579
|
+
{creating ? (
|
|
1580
|
+
<CreateModal routes={routes}
|
|
1581
|
+
own={own}
|
|
1582
|
+
onClose={() => setCreating(false)}
|
|
1583
|
+
onJump={(name) => {
|
|
1584
|
+
setCreating(false);
|
|
1585
|
+
reveal(name);
|
|
1586
|
+
}}
|
|
1587
|
+
onCreated={(name, draftId) => {
|
|
1588
|
+
setCreating(false);
|
|
1589
|
+
reveal(name);
|
|
1590
|
+
opener.openDraft(name, draftId);
|
|
1591
|
+
}} />
|
|
1592
|
+
) : null}
|
|
1593
|
+
</div>
|
|
1594
|
+
</TooltipHost>
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/** the list page of the workbench asked for a list that does not exist: say so, offer the way back */
|
|
1599
|
+
function NoSuchList({ asked }: { asked: string }) {
|
|
1600
|
+
const route = useRoute();
|
|
1601
|
+
const base = basePathOf(route?.pattern ?? null, route?.path ?? '/');
|
|
1602
|
+
return (
|
|
1603
|
+
<div className="pages-panel pages-panel--none"
|
|
1604
|
+
data-test-id="pages-panel-none">
|
|
1605
|
+
<p>There is no list called “{asked}” — the workbench has pages, prefabs and tile palettes.</p>
|
|
1606
|
+
<p>
|
|
1607
|
+
<Link href={`${base}/pages`}>The pages</Link>
|
|
1608
|
+
{' · '}
|
|
1609
|
+
<Link href={`${base}/prefabs`}>The prefabs</Link>
|
|
1610
|
+
</p>
|
|
1611
|
+
</div>
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// a function DECLARATION, not an arrow in the module literal: the component carries its own name
|
|
1616
|
+
function PagesPanel() {
|
|
1617
|
+
const { kind: ownKind, compact = false, kindParam } = useSettings<IPagesPanelSettings>();
|
|
1618
|
+
const route = useRoute();
|
|
1619
|
+
// the kind: the setting, else the page's route parameter the setting names (the list page) —
|
|
1620
|
+
// read off the location where no route was stamped (a document mounted by hand)
|
|
1621
|
+
const named = kindParam ? route?.params[kindParam] : undefined;
|
|
1622
|
+
const located = kindParam && !route && typeof window !== 'undefined'
|
|
1623
|
+
? parseWorkbenchPath(window.location.pathname, basePathOf(null, window.location.pathname), new URLSearchParams(window.location.search))
|
|
1624
|
+
: null;
|
|
1625
|
+
const asked = typeof named === 'string' ? named : Array.isArray(named) ? named.join('/')
|
|
1626
|
+
: located && (located.view === 'list' || located.view === 'editor') ? located.kind
|
|
1627
|
+
: located?.view === 'distributions' ? DISTRIBUTIONS_SEGMENT : undefined;
|
|
1628
|
+
// the list page also shows where the blocks come from (ADR-0059): `/workbench/distributions`
|
|
1629
|
+
if (!ownKind && asked === DISTRIBUTIONS_SEGMENT) return <DistributionsView />;
|
|
1630
|
+
const kind: ContentKind | null = ownKind ?? (asked === undefined ? 'pages' : kindOfSegment(asked));
|
|
1631
|
+
if (kind === null) return <NoSuchList asked={asked ?? ''} />;
|
|
1632
|
+
const words = kind === 'prefabs' ? PREFAB_WORDS : PAGE_WORDS;
|
|
1633
|
+
return (
|
|
1634
|
+
<EntityWords.Provider value={words}>
|
|
1635
|
+
<EntityPanel compact={compact} />
|
|
1636
|
+
</EntityWords.Provider>
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
export default markInternalTile(defineComponentSide<IPagesPanelSettings>({
|
|
1641
|
+
Component: PagesPanel,
|
|
1642
|
+
defaultSettings() {
|
|
1643
|
+
return { compact: false };
|
|
1644
|
+
},
|
|
1645
|
+
}));
|