@cosmicdrift/kumiko-samples 0.393.0 → 0.395.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 (226) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +15 -5
  3. package/packages/bundled-features/src/account-security/README.md +1 -1
  4. package/packages/bundled-features/src/account-security/changes.json +6 -0
  5. package/packages/bundled-features/src/account-security/feature.ts +9 -1
  6. package/packages/bundled-features/src/audit/event-classification.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
  9. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
  10. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
  11. package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
  12. package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
  13. package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
  14. package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
  16. package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
  18. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  19. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  20. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
  21. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
  22. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  23. package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
  24. package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
  25. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  26. package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
  27. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
  28. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
  29. package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
  30. package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
  31. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  32. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
  33. package/packages/bundled-features/src/cap-overview/types.ts +2 -0
  34. package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
  35. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
  36. package/packages/bundled-features/src/files/changes.json +18 -0
  37. package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
  38. package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
  39. package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
  40. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
  41. package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
  42. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +143 -0
  43. package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
  44. package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
  46. package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
  47. package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
  48. package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
  50. package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
  51. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
  52. package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
  53. package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
  56. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
  57. package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
  58. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
  59. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
  60. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
  61. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
  62. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
  63. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
  64. package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
  65. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
  71. package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
  75. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -2
  76. package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +93 -15
  78. package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
  80. package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
  81. package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
  83. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
  85. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
  89. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
  90. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
  91. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
  92. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
  93. package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
  94. package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
  95. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
  96. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +238 -89
  97. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
  98. package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
  99. package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
  100. package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
  101. package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
  102. package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
  103. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
  104. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
  105. package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
  106. package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
  107. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
  108. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
  109. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
  110. package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
  111. package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
  112. package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
  113. package/packages/bundled-features/src/inbound-provider-imap/changes.json +36 -0
  114. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +124 -41
  115. package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
  116. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +2 -1
  117. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
  118. package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
  119. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +61 -136
  120. package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
  121. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +9 -0
  122. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
  123. package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
  124. package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
  125. package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
  126. package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +4 -6
  127. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +75 -45
  128. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +19 -8
  129. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +203 -109
  130. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
  131. package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
  132. package/packages/bundled-features/src/inbound-provider-imap/watch-mailbox.ts +109 -0
  133. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
  134. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
  135. package/packages/bundled-features/src/jobs/changes.json +18 -0
  136. package/packages/bundled-features/src/managed-pages/changes.json +7 -0
  137. package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
  138. package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
  139. package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
  140. package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
  141. package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
  142. package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
  143. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
  144. package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
  145. package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
  146. package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
  147. package/packages/bundled-features/src/managed-pages/table.ts +6 -2
  148. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
  149. package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
  150. package/packages/bundled-features/src/managed-pages/web/i18n.ts +14 -0
  151. package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
  152. package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
  153. package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
  154. package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
  155. package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
  156. package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
  157. package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
  158. package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
  159. package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
  160. package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
  161. package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
  162. package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
  163. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
  164. package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
  165. package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
  166. package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
  167. package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
  168. package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
  169. package/packages/bundled-features/src/template-resolver/README.md +2 -2
  170. package/packages/bundled-features/src/template-resolver/changes.json +20 -0
  171. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
  172. package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
  173. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
  174. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
  175. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
  176. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +32 -0
  177. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
  178. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
  179. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +13 -480
  180. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
  181. package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
  182. package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
  183. package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
  184. package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
  185. package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
  186. package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
  187. package/packages/bundled-features/src/template-resolver/web/i18n.ts +9 -2
  188. package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
  189. package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
  190. package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
  191. package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
  192. package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
  193. package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
  194. package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
  195. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  196. package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
  197. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  198. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
  199. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
  200. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  201. package/samples/apps/use-all-bundled/feature-manifest.json +89 -40
  202. package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
  203. package/samples/apps/use-all-bundled/package.json +1 -0
  204. package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
  205. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
  206. package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
  207. package/samples/apps/use-all-bundled/src/app/client.tsx +8 -1
  208. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
  209. package/samples/apps/use-all-bundled/src/app/seed.ts +17 -2
  210. package/samples/apps/use-all-bundled/src/run-config.ts +19 -11
  211. package/samples/recipes/cross-feature-events/README.md +10 -0
  212. package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
  213. package/samples/recipes/dashboard-stats/README.md +8 -0
  214. package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
  215. package/samples/recipes/env-composition/README.md +2 -2
  216. package/samples/recipes/env-composition/src/env.ts +2 -2
  217. package/samples/recipes/inbound-mail-triage/README.md +8 -0
  218. package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
  219. package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
  220. package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
  221. package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
  222. package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
  223. package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
  224. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
  225. package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
  226. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
@@ -0,0 +1,61 @@
1
+ import { ContentPreview, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import { type ReactNode, useId, useState } from "react";
3
+ import { DocumentBlocksContext } from "./document-editor.tsx";
4
+ import type { CollectionEditorContext } from "./use-collection-editor-context.ts";
5
+
6
+ export type ContentFieldProps = {
7
+ readonly editorContext: CollectionEditorContext;
8
+ readonly value: string;
9
+ readonly onChange: (value: string) => void;
10
+ readonly disabled: boolean;
11
+ };
12
+
13
+ // The content input shared by the edit and create forms: the collection's editor
14
+ // plus a toggle to its preview with the example variables substituted.
15
+ export function ContentField({
16
+ editorContext,
17
+ value,
18
+ onChange,
19
+ disabled,
20
+ }: ContentFieldProps): ReactNode {
21
+ const { Field, Button } = usePrimitives();
22
+ const t = useTranslation();
23
+ const contentEditorId = useId();
24
+ const [previewing, setPreviewing] = useState(false);
25
+ const { ContentEditor, contentFormat, documentBlocks, variableExamples, variables } =
26
+ editorContext;
27
+ return (
28
+ <Field
29
+ id={contentEditorId}
30
+ label={t("template-resolver.editor.contentLabel")}
31
+ labelAppendix={
32
+ <Button
33
+ type="button"
34
+ variant="secondary"
35
+ size="sm"
36
+ onClick={() => setPreviewing((p) => !p)}
37
+ >
38
+ {previewing ? t("kumiko.contentEditor.editMode") : t("kumiko.contentEditor.preview")}
39
+ </Button>
40
+ }
41
+ >
42
+ {previewing ? (
43
+ <ContentPreview
44
+ content={value}
45
+ variables={variableExamples}
46
+ contentFormat={contentFormat}
47
+ />
48
+ ) : (
49
+ <DocumentBlocksContext.Provider value={documentBlocks}>
50
+ <ContentEditor
51
+ id={contentEditorId}
52
+ value={value}
53
+ onChange={onChange}
54
+ variables={variables}
55
+ readOnly={disabled}
56
+ />
57
+ </DocumentBlocksContext.Provider>
58
+ )}
59
+ </Field>
60
+ );
61
+ }
@@ -0,0 +1,256 @@
1
+ // Content tree behind the text-block and collection navs: loads the blocks from the
2
+ // by-tenant (or a collection's list) query, groups them by their `folder` field and
3
+ // emits them as TreeNode[].
4
+ //
5
+ // **Folder grouping**: block.folder !== null puts the node under a container
6
+ // node labelled with the folder; folder === null keeps it at the root.
7
+ //
8
+ // **State**: TreeNode.state = "filled" when content is set, otherwise "stub"
9
+ // (a hint that the slug exists but is empty).
10
+
11
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-renderer/live";
13
+ import type { ContentCollectionSchema } from "./collection-lookup.ts";
14
+ import { collectionQueryName, TemplateResolverQueries } from "./qualified-names.ts";
15
+ import { TENANT_OVERRIDE_DENIED_REASON } from "./tenant-override.ts";
16
+
17
+ export type BlockSummary = {
18
+ readonly slug: string;
19
+ readonly locale: string;
20
+ readonly title: string | null;
21
+ readonly content: string | null;
22
+ readonly folder: string | null;
23
+ readonly modifiedAt: string | null;
24
+ };
25
+
26
+ type ByTenantResponse = {
27
+ readonly data: { readonly blocks: readonly BlockSummary[] };
28
+ };
29
+
30
+ // Multi-level folder splitting: `folder` is a `/`-separated path, the tree
31
+ // splits per segment and creates nested folder nodes:
32
+ // block.folder = null → root leaf
33
+ // block.folder = "page" → folder page > leaf
34
+ // block.folder = "page/marketing" → folder page > folder marketing > leaf
35
+ //
36
+ // **Folder/leaf collision**: a block with `folder=null, slug="page"` and
37
+ // another with `folder="page"` both live on the same level — the folder with
38
+ // children plus a clickable leaf. Visually distinct (chevron + folder icon),
39
+ // no crash, no surprise.
40
+
41
+ type FolderNode = {
42
+ readonly leaves: TreeNode[];
43
+ readonly subFolders: Map<string, FolderNode>;
44
+ };
45
+
46
+ function newFolderNode(): FolderNode {
47
+ return { leaves: [], subFolders: new Map() };
48
+ }
49
+
50
+ function attachBlock(
51
+ root: FolderNode,
52
+ block: BlockSummary,
53
+ tenantIdOverride?: string,
54
+ collectionId?: string,
55
+ ): void {
56
+ const leaf: TreeNode = {
57
+ label: block.title || block.slug,
58
+ icon: "file",
59
+ target: {
60
+ featureId: "template-resolver",
61
+ action: "edit",
62
+ // tenantIdOverride and collectionId travel with the node → the editor
63
+ // reads and writes through the same tenant and the same collection the
64
+ // tree was loaded from. Omitted → session tenant, public text-blocks.
65
+ args: {
66
+ slug: block.slug,
67
+ locale: block.locale,
68
+ ...(tenantIdOverride !== undefined && { tenantIdOverride }),
69
+ ...(collectionId !== undefined && { collectionId }),
70
+ },
71
+ },
72
+ state: block.content ? "filled" : "stub",
73
+ };
74
+ let cur = root;
75
+ for (const segment of block.folder === null ? [] : block.folder.split("/")) {
76
+ let next = cur.subFolders.get(segment);
77
+ if (next === undefined) {
78
+ next = newFolderNode();
79
+ cur.subFolders.set(segment, next);
80
+ }
81
+ cur = next;
82
+ }
83
+ cur.leaves.push(leaf);
84
+ }
85
+
86
+ function renderFolderNode(node: FolderNode): TreeNode[] {
87
+ // Leaves first (root items), then folders alphabetically.
88
+ const result: TreeNode[] = [...node.leaves];
89
+ for (const folderName of [...node.subFolders.keys()].sort()) {
90
+ const subNode = node.subFolders.get(folderName);
91
+ if (subNode === undefined) continue;
92
+ result.push({
93
+ label: folderName,
94
+ icon: "folder",
95
+ state: "filled",
96
+ children: renderFolderNode(subNode),
97
+ });
98
+ }
99
+ return result;
100
+ }
101
+
102
+ export function groupBlocksByFolder(
103
+ blocks: readonly BlockSummary[],
104
+ tenantIdOverride?: string,
105
+ collectionId?: string,
106
+ ): readonly TreeNode[] {
107
+ const root = newFolderNode();
108
+ for (const block of blocks) {
109
+ attachBlock(root, block, tenantIdOverride, collectionId);
110
+ }
111
+ const rendered = renderFolderNode(root);
112
+ if (rendered.length === 0) return [];
113
+ return [
114
+ {
115
+ label: "Content",
116
+ icon: "folder",
117
+ state: "filled",
118
+ children: rendered,
119
+ },
120
+ ];
121
+ }
122
+
123
+ // A save in the editor re-fires the tree of the collection it wrote to. The
124
+ // SSE refresh does not cover every collection: per-user entries carry no
125
+ // entity signal, because naming their ids to every collection reader would
126
+ // reveal when colleagues change their own entries.
127
+ export const TEXT_BLOCK_TREE_KEY = "";
128
+ const treeRefreshListeners = new Map<string, Set<() => void>>();
129
+
130
+ function onTreeRefresh(treeKey: string, listener: () => void): () => void {
131
+ const listeners = treeRefreshListeners.get(treeKey) ?? new Set();
132
+ listeners.add(listener);
133
+ treeRefreshListeners.set(treeKey, listeners);
134
+ return () => listeners.delete(listener);
135
+ }
136
+
137
+ export function refreshTree(treeKey: string): void {
138
+ for (const listener of treeRefreshListeners.get(treeKey) ?? []) listener();
139
+ }
140
+
141
+ type BlockListResult =
142
+ | { readonly ok: true; readonly blocks: readonly BlockSummary[] }
143
+ | { readonly ok: false; readonly status: number; readonly body: string };
144
+
145
+ async function postBlockList(
146
+ queryType: string,
147
+ tenantIdOverride: string | undefined,
148
+ ): Promise<BlockListResult> {
149
+ // CSRF header is mandatory on authenticated requests (auth-middleware
150
+ // double-submit pattern). Anonymous/pre-login has no token → header
151
+ // omitted → the server takes the anonymous path.
152
+ const headers: Record<string, string> = { "content-type": "application/json" };
153
+ const csrf = readCsrfToken();
154
+ if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
155
+ // kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
156
+ const response = await fetch("/api/query", {
157
+ method: "POST",
158
+ headers,
159
+ body: JSON.stringify({
160
+ type: queryType,
161
+ payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
162
+ }),
163
+ });
164
+ if (!response.ok) {
165
+ return {
166
+ ok: false,
167
+ status: response.status,
168
+ body: await response.text().catch(() => response.statusText),
169
+ };
170
+ }
171
+ const data: ByTenantResponse = await response.json();
172
+ return { ok: true, blocks: data.data.blocks };
173
+ }
174
+
175
+ // The dispatcher accepts tenantIdOverride from a SystemAdmin only, and the
176
+ // provider cannot see the session roles. Everyone else gets this exact denial
177
+ // and reads their session tenant instead, which is the only tenant they may
178
+ // edit anyway.
179
+ function isTenantOverrideDenied(result: BlockListResult): boolean {
180
+ return !result.ok && result.status === 403 && result.body.includes(TENANT_OVERRIDE_DENIED_REASON);
181
+ }
182
+
183
+ async function loadBlocks(
184
+ queryType: string,
185
+ tenantIdOverride: string | undefined,
186
+ ): Promise<{ readonly blocks: readonly BlockSummary[]; readonly tenantIdOverride?: string }> {
187
+ let result = await postBlockList(queryType, tenantIdOverride);
188
+ let effectiveOverride = tenantIdOverride;
189
+ if (tenantIdOverride !== undefined && isTenantOverrideDenied(result)) {
190
+ result = await postBlockList(queryType, undefined);
191
+ effectiveOverride = undefined;
192
+ }
193
+ if (!result.ok) {
194
+ // 403 (CSRF/auth) or 5xx — explicit error instead of silent empty.
195
+ throw new Error(`text-block load failed: ${result.status} ${result.body}`);
196
+ }
197
+ return {
198
+ blocks: result.blocks,
199
+ ...(effectiveOverride !== undefined && { tenantIdOverride: effectiveOverride }),
200
+ };
201
+ }
202
+
203
+ // tenantIdOverride (SystemAdmin-only) lets an app point the content tree at a
204
+ // tenant other than the session's — publicstatus seeds marketing/legal blocks
205
+ // on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
206
+ // Without a collection: the anonymous-capable by-tenant query, so a public
207
+ // page keeps its content sidebar without a session. With one: that
208
+ // collection's own list handler, which carries the collection's access rule.
209
+ export function makeTreeProvider(
210
+ tenantIdOverride?: string,
211
+ collection?: Pick<ContentCollectionSchema, "id" | "ownership">,
212
+ ): TreeChildrenSubscribe {
213
+ const collectionId = collection?.id;
214
+ const queryType =
215
+ collectionId === undefined
216
+ ? TemplateResolverQueries.byTenant
217
+ : collectionQueryName(collectionId, "list");
218
+ // A per-user entry belongs to its author in the session tenant; pointing the
219
+ // tree at another tenant would only hide the author's own entries.
220
+ const requestedOverride = collection?.ownership === "user" ? undefined : tenantIdOverride;
221
+ return () => (emit, emitError) => {
222
+ let active = true;
223
+ // Each denial logs a client-fault warning on the server, so a reload after a
224
+ // save does not ask again once this subscription learned the answer.
225
+ let override = requestedOverride;
226
+ const load = (): void => {
227
+ loadBlocks(queryType, override)
228
+ .then(({ blocks, tenantIdOverride: effectiveOverride }) => {
229
+ // skip: subscription was cancelled while the request was in flight
230
+ if (!active) return;
231
+ override = effectiveOverride;
232
+ // The app-side r.nav node IS the "Content" container → the provider's
233
+ // children are the folders/leaves below it, not the wrapper.
234
+ const content = groupBlocksByFolder(blocks, effectiveOverride, collectionId)[0];
235
+ emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
236
+ })
237
+ .catch((e) => {
238
+ // skip: subscription was cancelled while the request was in flight
239
+ if (!active) return;
240
+ // Explicit error signal via emitError; ProviderBranch shows a banner
241
+ // plus retry. Falls back to emit([]) for consumers without emitError.
242
+ if (emitError) {
243
+ emitError(e instanceof Error ? e : new Error(String(e)));
244
+ } else {
245
+ emit([]);
246
+ }
247
+ });
248
+ };
249
+ load();
250
+ const unsubscribe = onTreeRefresh(collectionId ?? TEXT_BLOCK_TREE_KEY, load);
251
+ return () => {
252
+ active = false;
253
+ unsubscribe();
254
+ };
255
+ };
256
+ }
@@ -0,0 +1,12 @@
1
+ const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
2
+ const FORBIDDEN_REFERENCE_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
3
+
4
+ // Segments that hit the prototype chain are refused: the renderer later walks
5
+ // the dotted path into a data object. Kept apart from document-schema so client code
6
+ // can validate a name without loading tiptap.
7
+ export function isDocumentReferenceName(value: string): boolean {
8
+ return (
9
+ REFERENCE_NAME.test(value) &&
10
+ !value.split(".").some((segment) => FORBIDDEN_REFERENCE_SEGMENTS.has(segment))
11
+ );
12
+ }
@@ -8,20 +8,21 @@ import { Link } from "@tiptap/extension-link";
8
8
  import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
9
9
  import StarterKit from "@tiptap/starter-kit";
10
10
  import { createDocumentImagesNode } from "./document-images.ts";
11
+ import { isDocumentReferenceName } from "./document-reference-name.ts";
11
12
  import { createDocumentStepsNode } from "./document-steps.ts";
12
13
 
13
14
  const PLACEHOLDER_HREF = /^\{\{(.+)\}\}$/;
14
- const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
15
15
  const RESOURCE_KEY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
16
16
  const ALLOWED_LINK_SCHEMES = ["https://", "http://", "mailto:", "tel:"] as const;
17
17
 
18
+ export { isDocumentReferenceName };
19
+
18
20
  export function isDocumentPlaceholderHref(href: string): boolean {
19
21
  const name = PLACEHOLDER_HREF.exec(href)?.[1];
20
22
  return name !== undefined && isDocumentReferenceName(name);
21
23
  }
22
24
 
23
25
  const UNSAFE_URL_CHARACTERS = new Set(["<", ">", '"']);
24
- const FORBIDDEN_REFERENCE_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
25
26
 
26
27
  function hasUnsafeUrlCharacter(value: string): boolean {
27
28
  return Array.from(value).some((character) => {
@@ -53,15 +54,6 @@ export function isAllowedDocumentLinkHref(href: string): boolean {
53
54
  return hasAllowedLinkScheme(href) || isDocumentPlaceholderHref(href);
54
55
  }
55
56
 
56
- // Segments that hit the prototype chain are refused: the renderer later walks
57
- // the dotted path into a data object.
58
- export function isDocumentReferenceName(value: string): boolean {
59
- return (
60
- REFERENCE_NAME.test(value) &&
61
- !value.split(".").some((segment) => FORBIDDEN_REFERENCE_SEGMENTS.has(segment))
62
- );
63
- }
64
-
65
57
  export function isDocumentResourceKey(value: string): boolean {
66
58
  return RESOURCE_KEY.test(value);
67
59
  }
@@ -186,7 +186,14 @@ export const defaultTranslations: TranslationsByLocale = {
186
186
  "template-resolver.editor.networkError": "Network error while saving.",
187
187
  "template-resolver.editor.loading": "Loading current version…",
188
188
  "template-resolver.editor.loadFailed": "Could not load block",
189
- "template-resolver.editor.readOnly":
190
- "Read-only — TenantAdmin or SystemAdmin role required to make changes.",
189
+ "template-resolver.editor.create": "Create",
190
+ "template-resolver.editor.newEntry": "New entry",
191
+ "template-resolver.editor.newEntryTitle": "New {label}",
192
+ "template-resolver.editor.slugLabel": "Slug",
193
+ "template-resolver.editor.slugInvalid":
194
+ "Use lowercase letters, digits and dashes only; start with a letter or digit.",
195
+ "templateResolver.errors.collection.slugExists":
196
+ "An entry with the slug “{detail}” already exists. Pick another one.",
197
+ "template-resolver.editor.readOnly": "Read-only. Your role does not allow changes here.",
191
198
  },
192
199
  };
@@ -0,0 +1,30 @@
1
+ import { escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
2
+ import { isDocumentReferenceName } from "./document-reference-name.ts";
3
+
4
+ // Matches tags with quoted attribute values, so a `>` inside an attribute does not end the tag.
5
+ // The unquoted branch excludes `<` so a scan stops at the next tag start (no quadratic backtracking).
6
+ const HTML_TAG = /(<(?:"[^"]*"|'[^']*'|[^"'<>])*>)/;
7
+ // Triple braces are raw output and `{{else}}`/`{{this}}` belong to blocks; neither is a variable.
8
+ const TEXT_VARIABLE = /(?<!\{)\{\{\s*([^{}\s]+)\s*\}\}(?!\})/g;
9
+ const BLOCK_KEYWORDS: ReadonlySet<string> = new Set(["else", "this"]);
10
+
11
+ function isLegacyVariableName(name: string): boolean {
12
+ return isDocumentReferenceName(name) && !BLOCK_KEYWORDS.has(name);
13
+ }
14
+
15
+ // Content saved by the old editors holds `{{name}}` as plain text; the document editor
16
+ // keeps variables as data-variable nodes and would show it as literal text.
17
+ export function convertLegacyVariableText(html: string): string {
18
+ return html
19
+ .split(HTML_TAG)
20
+ .map((part, index) =>
21
+ index % 2 === 1
22
+ ? part
23
+ : part.replace(TEXT_VARIABLE, (match, name: string) =>
24
+ isLegacyVariableName(name)
25
+ ? `<span data-variable="${escapeHtmlAttr(name)}"></span>`
26
+ : match,
27
+ ),
28
+ )
29
+ .join("");
30
+ }
@@ -0,0 +1,7 @@
1
+ import type { DispatcherError } from "@cosmicdrift/kumiko-renderer/headless";
2
+
3
+ export const TENANT_OVERRIDE_DENIED_REASON = "tenant_override_requires_system_admin";
4
+
5
+ export function isTenantOverrideDeniedError(error: DispatcherError): boolean {
6
+ return error.details?.["reason"] === TENANT_OVERRIDE_DENIED_REASON;
7
+ }
@@ -0,0 +1,92 @@
1
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { FormScreenLayout, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ReactNode } from "react";
4
+ import { ContentField } from "./content-field.tsx";
5
+ import { useTextBlockEditor } from "./use-text-block-editor.ts";
6
+
7
+ type EditorState = ReturnType<typeof useTextBlockEditor>;
8
+
9
+ function LoadBanners({ state }: { readonly state: EditorState }): ReactNode {
10
+ const { Banner } = usePrimitives();
11
+ const t = useTranslation();
12
+ return (
13
+ <>
14
+ {state.loading && <Banner variant="loading">{t("template-resolver.editor.loading")}</Banner>}
15
+ {state.loadError !== null && (
16
+ <Banner variant="error">
17
+ {t("template-resolver.editor.loadFailed")}: {state.loadError.code}
18
+ </Banner>
19
+ )}
20
+ {!state.canWrite && !state.loading && (
21
+ <Banner variant="info">{t("template-resolver.editor.readOnly")}</Banner>
22
+ )}
23
+ </>
24
+ );
25
+ }
26
+
27
+ function SaveBanners({ state }: { readonly state: EditorState }): ReactNode {
28
+ const { Banner } = usePrimitives();
29
+ return (
30
+ <>
31
+ {state.saveError !== null && <Banner variant="error">{state.saveError}</Banner>}
32
+ {state.savedMsg !== null && <Banner variant="info">{state.savedMsg}</Banner>}
33
+ </>
34
+ );
35
+ }
36
+
37
+ export function TextBlockEditor({
38
+ target,
39
+ }: {
40
+ readonly target: TargetRef;
41
+ // onClose stays part of the resolver contract (the visual panel passes it)
42
+ // but is unused: tree selection navigates, there is no close button.
43
+ readonly onClose: () => void;
44
+ }): ReactNode {
45
+ const { Button, Field, Input, PageHeader, Section } = usePrimitives();
46
+ const t = useTranslation();
47
+ const state = useTextBlockEditor(target);
48
+ const headerTitle = state.title || state.slug || undefined;
49
+
50
+ return (
51
+ <>
52
+ {/* The editor opens beside the routed screen, so without this the shell header keeps that screen's title. */}
53
+ {PageHeader !== undefined && headerTitle !== undefined && <PageHeader title={headerTitle} />}
54
+ <FormScreenLayout
55
+ onSubmit={() => {
56
+ void state.save();
57
+ }}
58
+ testId="text-block-editor"
59
+ description={state.locale !== "" ? `${state.slug} · (${state.locale})` : state.slug}
60
+ actions={
61
+ state.canWrite ? (
62
+ <Button type="submit" loading={state.submitting} disabled={state.disabled}>
63
+ {t(state.saveLabelKey)}
64
+ </Button>
65
+ ) : undefined
66
+ }
67
+ >
68
+ <Section>
69
+ <LoadBanners state={state} />
70
+ <Field id="text-block-title" label={t("template-resolver.editor.titleLabel")} required>
71
+ <Input
72
+ kind="text"
73
+ id="text-block-title"
74
+ name="text-block-title"
75
+ value={state.title}
76
+ onChange={state.setTitle}
77
+ disabled={state.disabled}
78
+ required
79
+ />
80
+ </Field>
81
+ <ContentField
82
+ editorContext={state.editorContext}
83
+ value={state.content}
84
+ onChange={state.setContent}
85
+ disabled={state.disabled}
86
+ />
87
+ <SaveBanners state={state} />
88
+ </Section>
89
+ </FormScreenLayout>
90
+ </>
91
+ );
92
+ }
@@ -0,0 +1,42 @@
1
+ import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
2
+ import {
3
+ type ContentEditorComponent,
4
+ useAppFeatures,
5
+ useContentEditor,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import {
8
+ type ContentCollectionSchema,
9
+ canWriteContent,
10
+ findCollection,
11
+ } from "./collection-lookup.ts";
12
+ import type { DocumentToolbarBlock } from "./document-blocks.ts";
13
+ import { documentBlocksForKind } from "./document-toolbar-blocks.ts";
14
+
15
+ export type CollectionEditorContext = {
16
+ readonly collection: ContentCollectionSchema | undefined;
17
+ readonly contentFormat: ContentCollectionSchema["contentFormat"];
18
+ readonly ContentEditor: ContentEditorComponent;
19
+ readonly documentBlocks: readonly DocumentToolbarBlock[];
20
+ /** The collection's fixed variable names with the example value the preview substitutes. */
21
+ readonly variableExamples: Readonly<Record<string, string>>;
22
+ readonly variables: readonly string[];
23
+ readonly canWrite: boolean;
24
+ };
25
+
26
+ // No collection (the hand-wired text-block tree) → plain editor, no variables.
27
+ export function useCollectionEditorContext(collectionId?: string): CollectionEditorContext {
28
+ const features = useAppFeatures();
29
+ const user = useShellUser();
30
+ const collection = findCollection(features, collectionId);
31
+ const contentFormat = collection?.contentFormat;
32
+ const variableExamples = collection?.variableSchema ?? {};
33
+ return {
34
+ collection,
35
+ contentFormat,
36
+ ContentEditor: useContentEditor(contentFormat),
37
+ documentBlocks: documentBlocksForKind(collection?.kind),
38
+ variableExamples,
39
+ variables: Object.keys(variableExamples),
40
+ canWrite: canWriteContent(collection, user?.roles),
41
+ };
42
+ }
@@ -0,0 +1,92 @@
1
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { useLocale, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
+ import { useDispatchTarget } from "@cosmicdrift/kumiko-renderer/web";
4
+ import { useState } from "react";
5
+ import { SLUG_MAX_LENGTH, SLUG_PATTERN } from "./constants.ts";
6
+ import { refreshTree } from "./content-tree-provider.ts";
7
+ import { collectionHandlerName } from "./qualified-names.ts";
8
+ import { useCollectionEditorContext } from "./use-collection-editor-context.ts";
9
+ import { useContentWrite } from "./use-content-write.ts";
10
+
11
+ type CreateTargetArgs = { collectionId?: string; folder?: string };
12
+
13
+ function readCreateTargetArgs(target: TargetRef): {
14
+ readonly collectionId: string | undefined;
15
+ readonly folder: string | null;
16
+ } {
17
+ // @cast-boundary visual-tree-args — same de-erasure as the edit resolver; a missing
18
+ // collectionId (hand-edited URL) leaves the form without a collection to write to.
19
+ const args = target.args as CreateTargetArgs | undefined;
20
+ const folder = args?.folder !== undefined && args.folder !== "" ? args.folder : null;
21
+ return { collectionId: args?.collectionId, folder };
22
+ }
23
+
24
+ export function useCollectionEntryCreate(target: TargetRef, tenantId: string | undefined) {
25
+ const { collectionId, folder } = readCreateTargetArgs(target);
26
+ const t = useTranslation();
27
+ const locale = useLocale().locale();
28
+ const dispatchTarget = useDispatchTarget();
29
+ const editorContext = useCollectionEditorContext(collectionId);
30
+ const { collection, canWrite } = editorContext;
31
+ const { submitting, error, writeScoped } = useContentWrite();
32
+
33
+ const [slug, setSlug] = useState("");
34
+ const [title, setTitle] = useState("");
35
+ const [content, setContent] = useState("");
36
+
37
+ const slugValid = slug.length <= SLUG_MAX_LENGTH && SLUG_PATTERN.test(slug);
38
+ const disabled = submitting || !canWrite || collectionId === undefined;
39
+ const headerTitle = t("template-resolver.editor.newEntryTitle", {
40
+ label: collection !== undefined ? t(collection.nav.label) : "",
41
+ });
42
+
43
+ const create = async (): Promise<void> => {
44
+ // skip: nothing to write to, or the slug is invalid (the field already shows the issue)
45
+ if (collectionId === undefined || !slugValid) return;
46
+ const requestedOverride = collection?.ownership === "user" ? undefined : tenantId;
47
+ const written = await writeScoped(
48
+ collectionHandlerName(collectionId),
49
+ {
50
+ slug,
51
+ locale,
52
+ title,
53
+ content: content.length > 0 ? content : null,
54
+ folder,
55
+ createOnly: true,
56
+ },
57
+ requestedOverride,
58
+ );
59
+ // skip: useContentWrite already set the failure text
60
+ if (written === null) return;
61
+ const { response, tenantIdOverride } = written;
62
+ refreshTree(collectionId);
63
+ dispatchTarget({
64
+ featureId: "template-resolver",
65
+ action: "edit",
66
+ args: {
67
+ slug: response.slug,
68
+ locale: response.locale,
69
+ collectionId,
70
+ ...(tenantIdOverride !== undefined && { tenantIdOverride }),
71
+ },
72
+ });
73
+ };
74
+
75
+ return {
76
+ editorContext,
77
+ headerTitle,
78
+ slug,
79
+ setSlug,
80
+ title,
81
+ setTitle,
82
+ content,
83
+ setContent,
84
+ slugValid,
85
+ createDisabled: disabled || !slugValid || title === "",
86
+ slugInvalid: slug !== "" && !slugValid,
87
+ disabled,
88
+ submitting,
89
+ error,
90
+ create,
91
+ };
92
+ }