@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.
Files changed (115) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +10 -2
  4. package/mosetta.json +6 -0
  5. package/package.json +35 -3
  6. package/prefabs/workbench/prefab.json +11 -0
  7. package/prefabs/workbench/versions/1.json +120 -0
  8. package/prefabs/workbench/versions/2.json +13 -0
  9. package/prefabs/workbench/versions/3.json +19 -0
  10. package/prefabs/workbench/versions/4.json +29 -0
  11. package/prefabs/workbench/versions/5.json +29 -0
  12. package/tiles/canvas/canvas.component.tsx +2038 -0
  13. package/tiles/canvas/canvas.css +973 -0
  14. package/tiles/canvas/canvasTools.ts +66 -0
  15. package/tiles/canvas/chromeNodes.ts +35 -0
  16. package/tiles/canvas/ghosts.ts +33 -0
  17. package/tiles/canvas/insertPopover.tsx +56 -0
  18. package/tiles/canvas/popoverPlacement.ts +104 -0
  19. package/tiles/canvas/popoverSize.ts +87 -0
  20. package/tiles/canvas/providerChrome.tsx +191 -0
  21. package/tiles/canvas/scrollWatch.ts +43 -0
  22. package/tiles/canvas/settingsPopover.tsx +257 -0
  23. package/tiles/canvas/settingsTabs.ts +78 -0
  24. package/tiles/canvas/toolPanel.tsx +86 -0
  25. package/tiles/content/autoBind.ts +61 -0
  26. package/tiles/content/blockPage.css +390 -0
  27. package/tiles/content/blockPage.tsx +379 -0
  28. package/tiles/content/blockPicker.css +215 -0
  29. package/tiles/content/blockPicker.tsx +501 -0
  30. package/tiles/content/blockUpgrades.ts +145 -0
  31. package/tiles/content/blockVariants.ts +185 -0
  32. package/tiles/content/blockVocabulary.ts +77 -0
  33. package/tiles/content/catalogVersions.ts +106 -0
  34. package/tiles/content/componentPreview.css +82 -0
  35. package/tiles/content/componentPreview.tsx +249 -0
  36. package/tiles/content/content.server.ts +593 -0
  37. package/tiles/content/contentCommon.ts +133 -0
  38. package/tiles/content/disconnected.ts +56 -0
  39. package/tiles/content/distributions.css +256 -0
  40. package/tiles/content/distributions.tsx +271 -0
  41. package/tiles/content/editingAutosave.ts +125 -0
  42. package/tiles/content/editingClipboard.ts +185 -0
  43. package/tiles/content/editingConflicts.ts +223 -0
  44. package/tiles/content/editingHotkeys.ts +59 -0
  45. package/tiles/content/editingNotices.ts +105 -0
  46. package/tiles/content/formRoot.ts +18 -0
  47. package/tiles/content/fuzzySearch.ts +121 -0
  48. package/tiles/content/mediaUpload.ts +55 -0
  49. package/tiles/content/moduleSiblings.ts +9 -0
  50. package/tiles/content/mountedTree.ts +136 -0
  51. package/tiles/content/nodeSettings.css +483 -0
  52. package/tiles/content/nodeSettings.tsx +963 -0
  53. package/tiles/content/pasteText.ts +131 -0
  54. package/tiles/content/prefabFields.tsx +158 -0
  55. package/tiles/content/prefabParts.ts +105 -0
  56. package/tiles/content/previewEnvironment.ts +46 -0
  57. package/tiles/content/projectConfig.ts +46 -0
  58. package/tiles/content/projectPrefabs.ts +57 -0
  59. package/tiles/content/publishModal.css +109 -0
  60. package/tiles/content/publishModal.tsx +92 -0
  61. package/tiles/content/refsEditing.ts +412 -0
  62. package/tiles/content/scaledStage.ts +63 -0
  63. package/tiles/content/searchField.css +32 -0
  64. package/tiles/content/searchField.tsx +32 -0
  65. package/tiles/content/settingsDraft.ts +178 -0
  66. package/tiles/content/settingsIssues.tsx +124 -0
  67. package/tiles/content/themeScope.ts +72 -0
  68. package/tiles/content/themeVariables.ts +47 -0
  69. package/tiles/content/tooltipPlacement.ts +17 -0
  70. package/tiles/content/tooltips.tsx +131 -0
  71. package/tiles/content/treeMounter.ts +33 -0
  72. package/tiles/content/updatesModal.css +124 -0
  73. package/tiles/content/updatesModal.tsx +110 -0
  74. package/tiles/content/useCtrlHeld.ts +24 -0
  75. package/tiles/content/useEditingSession.ts +115 -0
  76. package/tiles/content/useMountedTree.ts +47 -0
  77. package/tiles/content/workbenchScale.css +27 -0
  78. package/tiles/hostFallback/hostFallback.component.tsx +32 -0
  79. package/tiles/hostFallback/hostFallback.css +12 -0
  80. package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
  81. package/tiles/page/page.component.tsx +7 -0
  82. package/tiles/page/page.script.tsx +5 -0
  83. package/tiles/pageModules/pageModules.component.tsx +740 -0
  84. package/tiles/pageModules/pageModules.css +383 -0
  85. package/tiles/pageModules/pageModules.icon.svg +1 -0
  86. package/tiles/pageModules/servedPage.ts +465 -0
  87. package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
  88. package/tiles/pagesPanel/pagesPanel.css +713 -0
  89. package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
  90. package/tiles/pagesPanel/pagesSearch.ts +194 -0
  91. package/tiles/pagesPanel/routeTree.ts +98 -0
  92. package/tiles/previewBridge/landing.ts +66 -0
  93. package/tiles/previewBridge/previewAgent.ts +457 -0
  94. package/tiles/previewBridge/previewBridge.component.tsx +137 -0
  95. package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
  96. package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
  97. package/tiles/previewBridge/standIn.ts +22 -0
  98. package/tiles/treeView/treeView.component.tsx +514 -0
  99. package/tiles/treeView/treeView.css +303 -0
  100. package/tiles/treeView/treeView.editor.tsx +12 -0
  101. package/tiles/workbench/assistant.css +401 -0
  102. package/tiles/workbench/assistant.tsx +516 -0
  103. package/tiles/workbench/assistantChat.ts +165 -0
  104. package/tiles/workbench/dictation.ts +354 -0
  105. package/tiles/workbench/projectInfo.ts +72 -0
  106. package/tiles/workbench/recent.ts +67 -0
  107. package/tiles/workbench/workbench.component.tsx +476 -0
  108. package/tiles/workbench/workbench.css +376 -0
  109. package/tiles/workbench/workbenchAddress.ts +202 -0
  110. package/tiles/workbench/workspace.ts +775 -0
  111. package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
  112. package/tiles/workbenchEditor/workbenchEditor.css +370 -0
  113. package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
  114. package/tiles/workbenchHome/workbenchHome.css +271 -0
  115. 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
+ }));