caracms 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,687 @@
1
+ /**
2
+ * The dialogs for creating and deleting documents (M5a Decisions 11 and 12; the
3
+ * mockup's #new-page, #new-group, #address-taken, #group-page, #new-case,
4
+ * #new-shared, #delete-page, #delete-parent, #shared-delete), in the editor's
5
+ * `#modal-root`. One at a time, from `dialogOf(editor)`:
6
+ *
7
+ * openDialog(editor, { kind: 'new', collection, inside?, at? }) New page / + New
8
+ * openDialog(editor, { kind: 'new-shared' }) + New shared block
9
+ * openDialog(editor, { kind: 'delete', ref }) Delete page… (the confirmation)
10
+ * openDialog(editor, { kind: 'in-use', ref, usedOn? }) a shared block still used
11
+ * openDialog(editor, { kind: 'move', ref }) Change address… (pre-M7 item 8)
12
+ * openDialog(editor, { kind: 'replace', key }) Replace… on a file (M6c G4, media/ReplaceDialog.tsx)
13
+ *
14
+ * Esc closes the dialog only (the editor's own Esc, going up a level, doesn't run).
15
+ * Addresses are shown before creating, made by slug.ts as the server makes them, and
16
+ * pre-checked with `addressProblem`; whatever the server still refuses, its own
17
+ * sentence shows on the field.
18
+ */
19
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
20
+ import { INDEX_MESSAGE, NO_ADDRESS_MESSAGE, RESERVED_SLUG, addressProblem, slugify } from '../../../slug.ts';
21
+ import { useEditor, useStore } from '../context.tsx';
22
+ import type { Editor } from '../editor.ts';
23
+ import { blockIcon } from '../block-list/summary.ts';
24
+ import { docIcon } from '../nav/DocRow.tsx';
25
+ import { Icon } from '../shell/Icons.tsx';
26
+ import { createStore, type Store } from '../state/store.ts';
27
+ import type { CreateDeleteRefusal, DocRef, ListedDocument } from '../../types.ts';
28
+ import { addressFor, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
29
+ import { filesLabel, inboxFor } from '../inbox/store.ts';
30
+ import { ReplaceDialog } from '../media/ReplaceDialog.tsx';
31
+
32
+ export type Dialog =
33
+ | { kind: 'new'; collection: string; inside?: string; at?: string }
34
+ | { kind: 'new-shared' }
35
+ | { kind: 'delete'; ref: DocRef }
36
+ | { kind: 'in-use'; ref: DocRef; usedOn?: CreateDeleteRefusal['usedOn'] }
37
+ /** Pre-M7 item 8: Change address…, in the language of New page. */
38
+ | { kind: 'move'; ref: DocRef }
39
+ /** M6 Decision 9: a submission's Delete…, permanent, so it asks first (the mockup's #submission-delete). */
40
+ | { kind: 'sub-delete'; id: string; who: string; files: number }
41
+ /** M6c G4 Decision 9: Replace… on a file in the library (its key). */
42
+ | { kind: 'replace'; key: string };
43
+
44
+ const dialogs = new WeakMap<Editor, Store<Dialog | null>>();
45
+
46
+ export function dialogOf(editor: Editor): Store<Dialog | null> {
47
+ let store = dialogs.get(editor);
48
+ if (!store) dialogs.set(editor, (store = createStore<Dialog | null>(null)));
49
+ return store;
50
+ }
51
+ export const openDialog = (editor: Editor, dialog: Dialog) => {
52
+ editor.setPop(null);
53
+ dialogOf(editor).set(dialog);
54
+ };
55
+ export const closeDialog = (editor: Editor) => dialogOf(editor).set(null);
56
+
57
+ /** Whichever dialog is open. Mounted once, in the shell's `#modal-root`. */
58
+ export function DocumentDialogs() {
59
+ const editor = useEditor();
60
+ const dialog = useStore(dialogOf(editor));
61
+ if (!dialog) return null;
62
+ switch (dialog.kind) {
63
+ case 'new':
64
+ return <NewDocument key={`${dialog.collection}:${dialog.inside ?? ''}:${dialog.at ?? ''}`} {...dialog} />;
65
+ case 'new-shared':
66
+ return <NewShared />;
67
+ case 'delete':
68
+ return <DeleteDocument key={`${dialog.ref.collection}/${dialog.ref.path}`} docRef={dialog.ref} />;
69
+ case 'in-use':
70
+ return <InUse docRef={dialog.ref} usedOn={dialog.usedOn} />;
71
+ case 'move':
72
+ return <MoveDocument key={`${dialog.ref.collection}/${dialog.ref.path}`} docRef={dialog.ref} />;
73
+ case 'sub-delete':
74
+ return <DeleteSubmission key={dialog.id} {...dialog} />;
75
+ case 'replace':
76
+ return <ReplaceDialog key={dialog.key} fileKey={dialog.key} />;
77
+ }
78
+ }
79
+
80
+ /** The dialog frame: the scrim, focus on its first control, Esc closing it alone, focus back where it was. */
81
+ export function Frame(props: { alert?: boolean; label: string; describedBy?: string; children: ReactNode }) {
82
+ const editor = useEditor();
83
+ const box = useRef<HTMLDivElement>(null);
84
+ useEffect(() => {
85
+ const before = document.activeElement as HTMLElement | null;
86
+ const first =
87
+ box.current?.querySelector<HTMLElement>('[data-autofocus]') ??
88
+ box.current?.querySelector<HTMLElement>('input:not([disabled]),select:not([disabled]),[role="radio"],button.btn:not([disabled])');
89
+ first?.focus();
90
+ const onKey = (event: KeyboardEvent) => {
91
+ if (event.key === 'Escape') {
92
+ // Closes the dialog only: the editor's Esc (up a level) and the picker's don't run.
93
+ event.preventDefault();
94
+ event.stopImmediatePropagation();
95
+ closeDialog(editor);
96
+ } else if (event.key === 'Tab' && box.current) {
97
+ // Keep focus inside the dialog.
98
+ const items = [...box.current.querySelectorAll<HTMLElement>('input:not([disabled]),select:not([disabled]),button:not([disabled])')];
99
+ const at = items.indexOf(document.activeElement as HTMLElement);
100
+ if (event.shiftKey && at <= 0) {
101
+ event.preventDefault();
102
+ items.at(-1)?.focus();
103
+ } else if (!event.shiftKey && at === items.length - 1) {
104
+ event.preventDefault();
105
+ items[0]?.focus();
106
+ }
107
+ }
108
+ };
109
+ window.addEventListener('keydown', onKey, true);
110
+ return () => {
111
+ window.removeEventListener('keydown', onKey, true);
112
+ if (before?.isConnected) before.focus({ preventScroll: true });
113
+ };
114
+ }, [editor]);
115
+ return (
116
+ <div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && closeDialog(editor)}>
117
+ <div ref={box} className="dialog" role={props.alert ? 'alertdialog' : 'dialog'} aria-modal="true" aria-labelledby="dlg-h" aria-describedby={props.describedBy} data-dialog={props.label}>
118
+ {props.children}
119
+ </div>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function Err({ id, text }: { id: string; text: string | null }) {
125
+ if (!text) return null;
126
+ return (
127
+ <p className="field-err" id={id} role="alert">
128
+ <Icon name="warn" size="sm" />
129
+ <span>{text}</span>
130
+ </p>
131
+ );
132
+ }
133
+
134
+ // ── New page, + New ──────────────────────────────────────────────────────────
135
+
136
+ const NEW_GROUP = '__new';
137
+
138
+ function NewDocument(props: { collection: string; inside?: string; at?: string }) {
139
+ const editor = useEditor();
140
+ const documents = useStore(editor.documents);
141
+ const collection = editor.site.collections.find((c) => c.name === props.collection);
142
+ const at = props.at ?? null;
143
+ const atLabel = at ? (insideOptions(collection ?? { name: props.collection, urlBase: '/' }, documents).find((o) => o.path === at)?.label.split(' / ').pop() ?? at) : null;
144
+ const [name, setName] = useState(atLabel ?? '');
145
+ const [inside, setInside] = useState(props.inside ?? '');
146
+ const [group, setGroup] = useState('');
147
+ /** The address's last part as typed by hand (null: it follows the name). */
148
+ const [part, setPart] = useState<string | null>(null);
149
+ const [error, setError] = useState<string | null>(null);
150
+ const [busy, setBusy] = useState(false);
151
+ const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents) : []), [collection, documents]);
152
+ if (!collection) return null;
153
+ const nested = collection.nested;
154
+ const what = singular(collection);
155
+ // M6c (G1): a collection with no pages of its own makes a file from the name, and shows no address.
156
+ const pageless = collection.urlBase === null;
157
+ const newGroup = nested && !at && inside === NEW_GROUP;
158
+ const folder = at ? '' : nested ? (newGroup ? slugify(group) : inside) : '';
159
+ const last = at ? at : slugify(part ?? name);
160
+ const path = at ? at : folder ? `${folder}/${last}` : last;
161
+ const url = addressFor(collection, path);
162
+ const base = addressFor(collection, folder ? `${folder}/` : '') ?? '/';
163
+ // What's wrong before asking the server (the same rules it applies).
164
+ const local = (() => {
165
+ if (!name.trim()) return null;
166
+ if (newGroup && !slugify(group)) return group.trim() ? NO_ADDRESS_MESSAGE : null;
167
+ if (!last) return NO_ADDRESS_MESSAGE;
168
+ if (path.split('/').includes(RESERVED_SLUG)) return INDEX_MESSAGE;
169
+ const existing = documents.find((d) => d.collection === collection.name && d.path === path);
170
+ if (existing) return url ? `There's already a ${what} at ${url}` : `There's already a ${what} called “${existing.label}”`;
171
+ return url ? (addressProblem(url, collection.name, editor.site.collections, documents, editor.site.codeRoutes)?.message ?? null) : null;
172
+ })();
173
+ const shown = error ?? local;
174
+ const blocked = !name.trim() || !last || (newGroup && !slugify(group)) || !!local || busy;
175
+ const create = async () => {
176
+ if (blocked) return;
177
+ setBusy(true);
178
+ setError(null);
179
+ const answer = await createDocument(editor, { collection: collection.name, path, title: name.trim() });
180
+ setBusy(false);
181
+ if (answer.ok) closeDialog(editor);
182
+ else setError(answer.error);
183
+ };
184
+ const kids = at ? pagesUnder(collection.name, at, documents) : [];
185
+ return (
186
+ <Frame label="new">
187
+ <form
188
+ onSubmit={(e) => {
189
+ e.preventDefault();
190
+ void create();
191
+ }}
192
+ >
193
+ <h2 id="dlg-h">{at ? `A page at ${url}` : `New ${what}`}</h2>
194
+ {editor.mode === 'live' ? <p>Nobody sees it until the next Publish.</p> : null}
195
+ {at && kids.length ? (
196
+ <div className="note" style={{ margin: 'var(--space-4) 0 0' }}>
197
+ <Icon name="folder" />
198
+ <div>
199
+ <b>{atLabel} becomes a page</b>
200
+ <p>
201
+ {andList(kids.map((k) => k.label))} stay{kids.length === 1 ? 's' : ''} under it, at the same address{kids.length === 1 ? '' : 'es'}.
202
+ </p>
203
+ </div>
204
+ </div>
205
+ ) : null}
206
+ {nested && !at ? (
207
+ <div className="field">
208
+ <label className="field-label" htmlFor="np-inside">
209
+ Inside
210
+ </label>
211
+ <select className="input" id="np-inside" value={inside} onChange={(e) => (setInside(e.target.value), setError(null))}>
212
+ {options.map((o) => (
213
+ <option key={o.path} value={o.path}>
214
+ {o.label}
215
+ {o.path ? ` (${o.base}…)` : ''}
216
+ </option>
217
+ ))}
218
+ <option disabled>──────────</option>
219
+ <option value={NEW_GROUP}>New group…</option>
220
+ </select>
221
+ </div>
222
+ ) : null}
223
+ {newGroup ? (
224
+ <div className="field">
225
+ <label className="field-label" htmlFor="np-group">
226
+ Group name
227
+ </label>
228
+ <input className="input" id="np-group" value={group} placeholder="e.g. Workshops" autoComplete="off" onChange={(e) => (setGroup(e.target.value), setError(null))} />
229
+ </div>
230
+ ) : null}
231
+ <div className="field">
232
+ <label className="field-label" htmlFor="np-name">
233
+ {newGroup ? 'First page' : 'Name'}
234
+ </label>
235
+ <input
236
+ className="input"
237
+ id="np-name"
238
+ data-autofocus=""
239
+ value={name}
240
+ placeholder={nested ? 'e.g. Packaging' : 'Name'}
241
+ autoComplete="off"
242
+ aria-invalid={shown ? true : undefined}
243
+ aria-describedby={shown ? 'np-err' : undefined}
244
+ onChange={(e) => (setName(e.target.value), setError(null))}
245
+ />
246
+ </div>
247
+ {pageless ? (
248
+ <Err id="np-err" text={shown} />
249
+ ) : (
250
+ <div className="field">
251
+ <p className="urlpre" style={{ marginTop: 0 }} data-address={url ?? ''}>
252
+ <Icon name="page" size="xs" />
253
+ <span>
254
+ Its address:{' '}
255
+ <span className="mono">
256
+ {editor.site.host}
257
+ {at ? <b>{url}</b> : (
258
+ <>
259
+ {base}
260
+ <b>{last || '…'}</b>
261
+ </>
262
+ )}
263
+ </span>
264
+ </span>
265
+ {at || part !== null ? null : (
266
+ <button type="button" className="linkbtn subtle" data-act="edit-address" onClick={() => setPart(last)}>
267
+ Change
268
+ </button>
269
+ )}
270
+ </p>
271
+ {part !== null ? (
272
+ <>
273
+ <label className="sub-label" htmlFor="np-part">
274
+ The address's last part
275
+ </label>
276
+ <input className="input mono" id="np-part" value={part} autoComplete="off" spellCheck={false} onChange={(e) => (setPart(e.target.value), setError(null))} />
277
+ </>
278
+ ) : null}
279
+ <Err id="np-err" text={shown} />
280
+ </div>
281
+ )}
282
+ <div className="acts">
283
+ <button type="button" className="btn" onClick={() => closeDialog(editor)}>
284
+ Cancel
285
+ </button>
286
+ <button type="submit" className="btn ink" data-act="create" disabled={blocked}>
287
+ {newGroup ? 'Create group and page' : `Create ${what}`}
288
+ </button>
289
+ </div>
290
+ </form>
291
+ </Frame>
292
+ );
293
+ }
294
+
295
+ // ── Change address… (pre-M7 item 8) ─────────────────────────────────────────
296
+
297
+ /**
298
+ * A document's new address: for nested Pages the group it sits in ("Inside", New page's
299
+ * select, with New group…) and the address's last part, shown whole as it's typed, with
300
+ * New page's rules checked before asking the server. Pages under it stay where they are.
301
+ */
302
+ function MoveDocument({ docRef }: { docRef: DocRef }) {
303
+ const editor = useEditor();
304
+ const documents = useStore(editor.documents);
305
+ const waiting = useStore(editor.liveState, (st) => st.status?.waiting);
306
+ const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
307
+ const collection = editor.site.collections.find((c) => c.name === docRef.collection);
308
+ const slash = docRef.path.lastIndexOf('/');
309
+ const [inside, setInside] = useState(slash === -1 ? '' : docRef.path.slice(0, slash));
310
+ const [group, setGroup] = useState('');
311
+ const [part, setPart] = useState(docRef.path.slice(slash + 1));
312
+ const [error, setError] = useState<string | null>(null);
313
+ const [busy, setBusy] = useState(false);
314
+ const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents) : []), [collection, documents]);
315
+ if (!doc || !collection || !doc.url) return null;
316
+ const nested = collection.nested;
317
+ const newGroup = nested && inside === NEW_GROUP;
318
+ const folder = nested ? (newGroup ? slugify(group) : inside) : '';
319
+ // As in New page: what's typed becomes an address part (`Harbour Roasters` → `harbour-roasters`).
320
+ const last = slugify(part);
321
+ const path = folder ? `${folder}/${last}` : last;
322
+ const url = addressFor(collection, path);
323
+ const base = addressFor(collection, folder ? `${folder}/` : '') ?? '/';
324
+ const same = path === docRef.path;
325
+ const others = documents.filter((d) => !(d.collection === docRef.collection && d.path === docRef.path));
326
+ // New page's rules, before asking the server (it applies the same).
327
+ const local = (() => {
328
+ if (same) return null;
329
+ if (newGroup && !slugify(group)) return group.trim() ? NO_ADDRESS_MESSAGE : null;
330
+ if (!last) return part.trim() ? NO_ADDRESS_MESSAGE : null;
331
+ if (path.split('/').includes(RESERVED_SLUG)) return INDEX_MESSAGE;
332
+ return url ? (addressProblem(url, collection.name, editor.site.collections, others, editor.site.codeRoutes)?.message ?? null) : null;
333
+ })();
334
+ const shown = error ?? local;
335
+ const blocked = same || !last || (newGroup && !slugify(group)) || !!local || busy;
336
+ // Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from.
337
+ const pending = (waiting ?? []).find((w) => w.collection === docRef.collection && w.path === docRef.path && w.change === 'new');
338
+ const liveAddress = pending ? (pending.movedFrom ?? null) : doc.url;
339
+ const kids = nested ? pagesUnder(collection.name, docRef.path, documents) : [];
340
+ const host = editor.site.host;
341
+ const confirm = async () => {
342
+ if (blocked) return;
343
+ setBusy(true);
344
+ setError(null);
345
+ const answer = await moveDocument(editor, docRef, path, doc.label);
346
+ setBusy(false);
347
+ if (answer.ok) closeDialog(editor);
348
+ else setError(answer.error);
349
+ };
350
+ const intro = liveAddress ? `${liveAddress} will redirect here.${editor.mode === 'live' ? ' Goes live at the next Publish.' : ''}` : null;
351
+ return (
352
+ <Frame label="move">
353
+ <form
354
+ onSubmit={(e) => {
355
+ e.preventDefault();
356
+ void confirm();
357
+ }}
358
+ >
359
+ <h2 id="dlg-h">Change {doc.label}’s address</h2>
360
+ {intro ? <p>{intro}</p> : null}
361
+ {nested ? (
362
+ <div className="field">
363
+ <label className="field-label" htmlFor="mv-inside">
364
+ Inside
365
+ </label>
366
+ <select className="input" id="mv-inside" value={inside} onChange={(e) => (setInside(e.target.value), setError(null))}>
367
+ {options.map((o) => (
368
+ <option key={o.path} value={o.path}>
369
+ {o.label}
370
+ {o.path ? ` (${o.base}…)` : ''}
371
+ </option>
372
+ ))}
373
+ <option disabled>──────────</option>
374
+ <option value={NEW_GROUP}>New group…</option>
375
+ </select>
376
+ </div>
377
+ ) : null}
378
+ {newGroup ? (
379
+ <div className="field">
380
+ <label className="field-label" htmlFor="mv-group">
381
+ Group name
382
+ </label>
383
+ <input className="input" id="mv-group" value={group} placeholder="e.g. Studio" autoComplete="off" onChange={(e) => (setGroup(e.target.value), setError(null))} />
384
+ </div>
385
+ ) : null}
386
+ <div className="field">
387
+ <label className="field-label" htmlFor="mv-part">
388
+ The address's last part
389
+ </label>
390
+ <input
391
+ className="input mono"
392
+ id="mv-part"
393
+ data-autofocus=""
394
+ value={part}
395
+ autoComplete="off"
396
+ spellCheck={false}
397
+ aria-invalid={shown ? true : undefined}
398
+ aria-describedby={shown ? 'mv-err' : undefined}
399
+ onChange={(e) => (setPart(e.target.value), setError(null))}
400
+ />
401
+ <p className="urlpre" data-address={url ?? ''}>
402
+ <Icon name="page" size="xs" />
403
+ <span>
404
+ {same ? 'Its address now: ' : 'Its new address: '}
405
+ <span className="mono">
406
+ {host}
407
+ {base}
408
+ <b>{last || '…'}</b>
409
+ </span>
410
+ </span>
411
+ </p>
412
+ <Err id="mv-err" text={shown} />
413
+ {same ? null : (
414
+ <p className="field-help" data-from={doc.url}>
415
+ Now at <span className="mono">{`${host}${doc.url}`}</span>
416
+ </p>
417
+ )}
418
+ </div>
419
+ {kids.length ? (
420
+ <p className="field-help" data-kids={kids.length}>
421
+ {andList(kids.map((k) => k.label))} stay{kids.length === 1 ? 's' : ''} where {kids.length === 1 ? 'it is' : 'they are'}, at {doc.url}/…
422
+ </p>
423
+ ) : null}
424
+ <div className="acts">
425
+ <button type="button" className="btn" onClick={() => closeDialog(editor)}>
426
+ Cancel
427
+ </button>
428
+ <button type="submit" className="btn ink" data-act="confirm-move" disabled={blocked}>
429
+ Change address
430
+ </button>
431
+ </div>
432
+ </form>
433
+ </Frame>
434
+ );
435
+ }
436
+
437
+ // ── + New shared block ───────────────────────────────────────────────────────
438
+
439
+ function NewShared() {
440
+ const editor = useEditor();
441
+ const documents = useStore(editor.documents);
442
+ const types = Object.values(editor.site.blocks);
443
+ const [type, setType] = useState<string | null>(null);
444
+ const [name, setName] = useState('');
445
+ const [error, setError] = useState<string | null>(null);
446
+ const [busy, setBusy] = useState(false);
447
+ const path = slugify(name);
448
+ const local = !name.trim()
449
+ ? null
450
+ : !path
451
+ ? NO_ADDRESS_MESSAGE
452
+ : path === RESERVED_SLUG
453
+ ? INDEX_MESSAGE
454
+ : documents.some((d) => d.collection === 'shared' && d.path === path)
455
+ ? `There's already a shared block called “${documents.find((d) => d.collection === 'shared' && d.path === path)!.label}”.`
456
+ : null;
457
+ const shown = error ?? local;
458
+ const blocked = !type || !name.trim() || !!local || busy;
459
+ const create = async () => {
460
+ if (blocked) return;
461
+ setBusy(true);
462
+ setError(null);
463
+ const answer = await createDocument(editor, { collection: 'shared', path, title: name.trim(), blockType: type! });
464
+ setBusy(false);
465
+ if (answer.ok) closeDialog(editor);
466
+ else setError(answer.error);
467
+ };
468
+ return (
469
+ <Frame label="new-shared">
470
+ <form
471
+ onSubmit={(e) => {
472
+ e.preventDefault();
473
+ void create();
474
+ }}
475
+ >
476
+ <h2 id="dlg-h">New shared block</h2>
477
+ <div className="field">
478
+ <span className="field-label" id="ns-t">
479
+ Block type
480
+ </span>
481
+ <div className="tgrid" role="radiogroup" aria-labelledby="ns-t" style={{ marginTop: 'var(--space-1-5)' }}>
482
+ {types.map((t) => (
483
+ <button key={t.type} type="button" className="ptype sm" role="radio" aria-checked={type === t.type} data-type={t.type} onClick={() => (setType(t.type), setError(null))}>
484
+ <Icon name={blockIcon({ type: t.type, known: true })} />
485
+ <b>{t.label}</b>
486
+ </button>
487
+ ))}
488
+ </div>
489
+ </div>
490
+ <div className="field">
491
+ <label className="field-label" htmlFor="ns-name">
492
+ Name
493
+ </label>
494
+ <input
495
+ className="input"
496
+ id="ns-name"
497
+ value={name}
498
+ placeholder="e.g. Client quote"
499
+ autoComplete="off"
500
+ aria-invalid={shown ? true : undefined}
501
+ aria-describedby={shown ? 'ns-err' : undefined}
502
+ onChange={(e) => (setName(e.target.value), setError(null))}
503
+ />
504
+ <p className="field-help">Visitors don't see it.</p>
505
+ <Err id="ns-err" text={shown} />
506
+ </div>
507
+ <div className="acts">
508
+ <button type="button" className="btn" onClick={() => closeDialog(editor)}>
509
+ Cancel
510
+ </button>
511
+ <button type="submit" className="btn ink" data-act="create-shared" disabled={blocked}>
512
+ Create shared block
513
+ </button>
514
+ </div>
515
+ </form>
516
+ </Frame>
517
+ );
518
+ }
519
+
520
+ // ── Delete ───────────────────────────────────────────────────────────────────
521
+
522
+ function DeleteDocument({ docRef }: { docRef: DocRef }) {
523
+ const editor = useEditor();
524
+ const documents = useStore(editor.documents);
525
+ const [error, setError] = useState<string | null>(null);
526
+ const [busy, setBusy] = useState(false);
527
+ const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
528
+ const collection = editor.site.collections.find((c) => c.name === docRef.collection);
529
+ if (!doc || !collection) return null;
530
+ const shared = collection.kind === 'shared';
531
+ const kids = collection.nested ? pagesUnder(collection.name, doc.path, documents) : [];
532
+ const host = editor.site.host;
533
+ const url = doc.url ?? '';
534
+ const confirm = async () => {
535
+ setBusy(true);
536
+ setError(null);
537
+ const answer = await deleteDocument(editor, docRef, doc.label);
538
+ setBusy(false);
539
+ if (answer.ok) closeDialog(editor);
540
+ else if (answer.code === 'in-use') openDialog(editor, { kind: 'in-use', ref: docRef, usedOn: answer.usedOn ?? undefined });
541
+ else setError(answer.error);
542
+ };
543
+ return (
544
+ <Frame alert label="delete" describedBy="dlg-d">
545
+ <div className="icon-h" style={{ background: 'var(--status-danger-wash)', color: 'var(--status-danger)' }}>
546
+ <Icon name="trash" />
547
+ </div>
548
+ <h2 id="dlg-h">Delete {doc.label}?</h2>
549
+ <p id="dlg-d">
550
+ {shared ? (
551
+ "It isn't on any page, so nothing on the site changes."
552
+ ) : collection.urlBase === null ? (
553
+ // M6c (G1): no page of its own, so no address stops working; pages showing it drop it.
554
+ editor.mode === 'live' ? (
555
+ `${doc.label} disappears from the pages that show it at the next Publish.`
556
+ ) : (
557
+ `${doc.label} is removed, and disappears from the pages that show it.`
558
+ )
559
+ ) : editor.mode === 'live' ? (
560
+ <>
561
+ <span className="mono">{`${host}${url === '/' ? '' : url}`}</span> stops working at the next Publish.
562
+ </>
563
+ ) : (
564
+ <>
565
+ {doc.label} is removed, and <span className="mono">{`${host}${url === '/' ? '' : url}`}</span> stops working.
566
+ </>
567
+ )}
568
+ </p>
569
+ {kids.length ? (
570
+ <p data-kids={kids.length}>
571
+ Its {kids.length} page{kids.length === 1 ? '' : 's'} stay{kids.length === 1 ? 's' : ''}, in a {doc.label} group: {andList(kids.map((k) => k.label))} keep
572
+ {kids.length === 1 ? 's its address' : ' their addresses'}.
573
+ </p>
574
+ ) : null}
575
+ <Err id="del-err" text={error} />
576
+ <div className="acts">
577
+ <button type="button" className="btn" onClick={() => closeDialog(editor)}>
578
+ Cancel
579
+ </button>
580
+ <button type="button" className="btn danger" data-act="confirm-delete" disabled={busy} onClick={() => void confirm()}>
581
+ Delete {shared ? 'shared block' : singular(collection)}
582
+ </button>
583
+ </div>
584
+ </Frame>
585
+ );
586
+ }
587
+
588
+ /**
589
+ * M6 Decision 9: deleting a submission is permanent (its files, then its message), so it
590
+ * asks first. Done, the sheet closes and the list drops it.
591
+ */
592
+ function DeleteSubmission({ id, who, files }: { id: string; who: string; files: number }) {
593
+ const editor = useEditor();
594
+ const [error, setError] = useState<string | null>(null);
595
+ const [busy, setBusy] = useState(false);
596
+ const confirm = async () => {
597
+ setBusy(true);
598
+ setError(null);
599
+ const answer = await inboxFor(editor).remove(id);
600
+ // The answer may come after this dialog was left and another message opened (delete one, then
601
+ // straight on to the next): it closes only its own dialog and its own sheet.
602
+ const mine = () => {
603
+ const open = dialogOf(editor).get();
604
+ return open?.kind === 'sub-delete' && open.id === id;
605
+ };
606
+ if (!answer.ok) {
607
+ const error = answer.error ?? "It wasn't deleted. Try again in a moment.";
608
+ if (mine()) {
609
+ setBusy(false);
610
+ setError(error);
611
+ } else editor.toast(`${who}’s message wasn’t deleted. ${error}`, 'warn');
612
+ return;
613
+ }
614
+ if (mine()) closeDialog(editor);
615
+ if (editor.view.get().inbox?.id === id) editor.openInbox(null);
616
+ editor.toast(`Deleted ${who}’s message${files ? ` and its ${filesLabel(files)}` : ''}`, 'trash');
617
+ };
618
+ return (
619
+ <Frame alert label="sub-delete" describedBy="dlg-d">
620
+ <div className="icon-h" style={{ background: 'var(--status-danger-wash)', color: 'var(--status-danger)' }}>
621
+ <Icon name="trash" />
622
+ </div>
623
+ <h2 id="dlg-h">Delete {who}’s message?</h2>
624
+ <p id="dlg-d">
625
+ It’s deleted for good{files ? `, with its ${filesLabel(files)}` : ''}. There’s no undo.
626
+ </p>
627
+ <Err id="del-err" text={error} />
628
+ <div className="acts">
629
+ <button type="button" className="btn" onClick={() => closeDialog(editor)}>
630
+ Cancel
631
+ </button>
632
+ <button type="button" className="btn danger" data-act="sub-delete-confirm" disabled={busy} onClick={() => void confirm()}>
633
+ Delete for good
634
+ </button>
635
+ </div>
636
+ </Frame>
637
+ );
638
+ }
639
+
640
+ /** A shared block that pages still use can't be deleted (Decision 8): where it's used, each a way there. */
641
+ function InUse({ docRef, usedOn }: { docRef: DocRef; usedOn?: CreateDeleteRefusal['usedOn'] }) {
642
+ const editor = useEditor();
643
+ const documents = useStore(editor.documents);
644
+ const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
645
+ const where: ListedDocument[] = (usedOn ?? doc?.usedOn ?? [])
646
+ .map((u) => documents.find((d) => d.collection === u.collection && d.path === u.path))
647
+ .filter((d): d is ListedDocument => !!d);
648
+ const title = doc?.label ?? docRef.path;
649
+ const n = where.length;
650
+ return (
651
+ <Frame alert label="in-use" describedBy="dlg-d">
652
+ <div className="icon-h" style={{ background: 'var(--status-warning-wash)', color: 'var(--status-warning)' }}>
653
+ <Icon name="shared" />
654
+ </div>
655
+ <h2 id="dlg-h">
656
+ {title} is still on {n} page{n === 1 ? '' : 's'}
657
+ </h2>
658
+ <p id="dlg-d">Remove it from these pages first.</p>
659
+ <div className="dlg-list">
660
+ {where.map((d) => (
661
+ <button
662
+ key={`${d.collection}/${d.path}`}
663
+ type="button"
664
+ className="drow"
665
+ data-doc={`${d.collection}/${d.path}`}
666
+ onClick={() => {
667
+ closeDialog(editor);
668
+ void editor.openDocument({ collection: d.collection, path: d.path });
669
+ }}
670
+ >
671
+ <Icon name={docIcon(editor.site.collections.find((c) => c.name === d.collection))} />
672
+ <span className="nm">{d.label}</span>
673
+ <span className="meta">
674
+ <span className="mono">{d.url}</span>
675
+ </span>
676
+ <Icon name="chev-r" size="sm" />
677
+ </button>
678
+ ))}
679
+ </div>
680
+ <div className="acts">
681
+ <button type="button" className="btn ink" onClick={() => closeDialog(editor)}>
682
+ OK
683
+ </button>
684
+ </div>
685
+ </Frame>
686
+ );
687
+ }