@cosmicdrift/kumiko-samples 0.394.0 → 0.396.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 (53) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  4. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +3 -104
  5. package/packages/bundled-features/src/inbound-provider-imap/watch-mailbox.ts +109 -0
  6. package/packages/bundled-features/src/managed-pages/changes.json +7 -0
  7. package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
  8. package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
  9. package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
  10. package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
  11. package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
  12. package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
  13. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
  14. package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
  15. package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
  16. package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
  17. package/packages/bundled-features/src/managed-pages/table.ts +6 -2
  18. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
  19. package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
  20. package/packages/bundled-features/src/managed-pages/web/i18n.ts +13 -0
  21. package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
  22. package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
  23. package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
  24. package/packages/bundled-features/src/template-resolver/README.md +2 -2
  25. package/packages/bundled-features/src/template-resolver/changes.json +8 -0
  26. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
  27. package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
  28. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
  29. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +7 -0
  30. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
  31. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
  32. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +8 -576
  33. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
  34. package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
  35. package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
  36. package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
  37. package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
  38. package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
  39. package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
  40. package/packages/bundled-features/src/template-resolver/web/i18n.ts +8 -0
  41. package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
  42. package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
  43. package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
  44. package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
  45. package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
  46. package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
  47. package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
  48. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  49. package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
  50. package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
  51. package/samples/apps/use-all-bundled/src/app/client.tsx +6 -1
  52. package/samples/apps/use-all-bundled/src/app/seed.ts +13 -1
  53. package/samples/apps/use-all-bundled/src/run-config.ts +12 -6
@@ -1,571 +1,9 @@
1
- import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-renderer/headless";
2
- // Client feature factory for the text-block content tree. Apps mount it via
3
- // createKumikoApp({ clientFeatures: [textBlocksClient()] }); it loads the
4
- // blocks from the by-tenant query, groups them by their `folder` field and
5
- // emits them as TreeNode[], plus the editor behind the edit action.
6
- //
7
- // **Folder grouping**: block.folder !== null puts the node under a container
8
- // node labelled with the folder; folder === null keeps it at the root.
9
- //
10
- // **State**: TreeNode.state = "filled" when content is set, otherwise "stub"
11
- // (a hint that the slug exists but is empty).
12
-
13
- import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
14
- import type {
15
- TargetRef,
16
- TreeChildrenSubscribe,
17
- TreeNode,
18
- } from "@cosmicdrift/kumiko-framework/engine";
19
- import { isUiAccessGranted } from "@cosmicdrift/kumiko-framework/ui-types";
20
- import {
21
- ContentPreview,
22
- type FeatureSchema,
23
- useAppFeatures,
24
- useContentEditor,
25
- useDispatcher,
26
- usePrimitives,
27
- useQuery,
28
- useTranslation,
29
- } from "@cosmicdrift/kumiko-renderer";
30
- import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-renderer/live";
31
- import {
32
- type ClientFeatureDefinition,
33
- PlainContentEditor,
34
- RichContentEditor,
35
- } from "@cosmicdrift/kumiko-renderer/web";
36
- import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
37
- import { type FormEvent, type ReactNode, useEffect, useId, useState } from "react";
38
- import { DEFAULT_COLLECTION_ACCESS } from "./constants.ts";
39
- import { DocumentBlocksContext, DocumentContentEditor } from "./document-editor.tsx";
40
- import { documentBlocksForKind } from "./document-toolbar-blocks.ts";
1
+ import { type ClientFeatureDefinition, PlainContentEditor } from "@cosmicdrift/kumiko-renderer/web";
2
+ import { createCollectionEntryCreateForm } from "./collection-entry-create-form.tsx";
3
+ import { makeTreeProvider } from "./content-tree-provider.ts";
4
+ import { DocumentContentEditor } from "./document-editor.tsx";
41
5
  import { defaultTranslations } from "./i18n.ts";
42
- import {
43
- collectionHandlerName,
44
- collectionQueryName,
45
- TemplateResolverHandlers,
46
- TemplateResolverQueries,
47
- } from "./qualified-names.ts";
48
-
49
- // Exported for the unit test — groupBlocksByFolder is a pure function.
50
- export type BlockSummary = {
51
- readonly slug: string;
52
- readonly locale: string;
53
- readonly title: string | null;
54
- readonly content: string | null;
55
- readonly folder: string | null;
56
- readonly modifiedAt: string | null;
57
- };
58
-
59
- type ByTenantResponse = {
60
- readonly data: { readonly blocks: readonly BlockSummary[] };
61
- };
62
-
63
- // Multi-level folder splitting: `folder` is a `/`-separated path, the tree
64
- // splits per segment and creates nested folder nodes:
65
- // block.folder = null → root leaf
66
- // block.folder = "page" → folder page > leaf
67
- // block.folder = "page/marketing" → folder page > folder marketing > leaf
68
- //
69
- // **Folder/leaf collision**: a block with `folder=null, slug="page"` and
70
- // another with `folder="page"` both live on the same level — the folder with
71
- // children plus a clickable leaf. Visually distinct (chevron + folder icon),
72
- // no crash, no surprise.
73
-
74
- type FolderNode = {
75
- readonly leaves: TreeNode[];
76
- readonly subFolders: Map<string, FolderNode>;
77
- };
78
-
79
- function newFolderNode(): FolderNode {
80
- return { leaves: [], subFolders: new Map() };
81
- }
82
-
83
- function attachBlock(
84
- root: FolderNode,
85
- block: BlockSummary,
86
- tenantIdOverride?: string,
87
- collectionId?: string,
88
- ): void {
89
- const leaf: TreeNode = {
90
- label: block.title || block.slug,
91
- icon: "file",
92
- target: {
93
- featureId: "template-resolver",
94
- action: "edit",
95
- // tenantIdOverride and collectionId travel with the node → the editor
96
- // reads and writes through the same tenant and the same collection the
97
- // tree was loaded from. Omitted → session tenant, public text-blocks.
98
- args: {
99
- slug: block.slug,
100
- locale: block.locale,
101
- ...(tenantIdOverride !== undefined && { tenantIdOverride }),
102
- ...(collectionId !== undefined && { collectionId }),
103
- },
104
- },
105
- state: block.content ? "filled" : "stub",
106
- };
107
- if (block.folder === null) {
108
- root.leaves.push(leaf);
109
- return;
110
- }
111
- let cur = root;
112
- for (const segment of block.folder.split("/")) {
113
- let next = cur.subFolders.get(segment);
114
- if (next === undefined) {
115
- next = newFolderNode();
116
- cur.subFolders.set(segment, next);
117
- }
118
- cur = next;
119
- }
120
- cur.leaves.push(leaf);
121
- }
122
-
123
- function renderFolderNode(node: FolderNode): TreeNode[] {
124
- // Leaves first (root items), then folders alphabetically.
125
- const result: TreeNode[] = [...node.leaves];
126
- for (const folderName of [...node.subFolders.keys()].sort()) {
127
- const subNode = node.subFolders.get(folderName);
128
- if (subNode === undefined) continue;
129
- result.push({
130
- label: folderName,
131
- icon: "folder",
132
- state: "filled",
133
- children: renderFolderNode(subNode),
134
- });
135
- }
136
- return result;
137
- }
138
-
139
- export function groupBlocksByFolder(
140
- blocks: readonly BlockSummary[],
141
- tenantIdOverride?: string,
142
- collectionId?: string,
143
- ): readonly TreeNode[] {
144
- const root = newFolderNode();
145
- for (const block of blocks) {
146
- attachBlock(root, block, tenantIdOverride, collectionId);
147
- }
148
- const rendered = renderFolderNode(root);
149
- if (rendered.length === 0) return [];
150
- return [
151
- {
152
- label: "Content",
153
- icon: "folder",
154
- state: "filled",
155
- children: rendered,
156
- },
157
- ];
158
- }
159
-
160
- // A save in the editor re-fires the tree of the collection it wrote to. The
161
- // SSE refresh does not cover every collection: per-user entries carry no
162
- // entity signal, because naming their ids to every collection reader would
163
- // reveal when colleagues change their own entries.
164
- const TEXT_BLOCK_TREE_KEY = "";
165
- const treeRefreshListeners = new Map<string, Set<() => void>>();
166
-
167
- function onTreeRefresh(treeKey: string, listener: () => void): () => void {
168
- const listeners = treeRefreshListeners.get(treeKey) ?? new Set();
169
- listeners.add(listener);
170
- treeRefreshListeners.set(treeKey, listeners);
171
- return () => listeners.delete(listener);
172
- }
173
-
174
- function refreshTree(treeKey: string): void {
175
- for (const listener of treeRefreshListeners.get(treeKey) ?? []) listener();
176
- }
177
-
178
- type BlockListResult =
179
- | { readonly ok: true; readonly blocks: readonly BlockSummary[] }
180
- | { readonly ok: false; readonly status: number; readonly body: string };
181
-
182
- async function postBlockList(
183
- queryType: string,
184
- tenantIdOverride: string | undefined,
185
- ): Promise<BlockListResult> {
186
- // CSRF header is mandatory on authenticated requests (auth-middleware
187
- // double-submit pattern). Anonymous/pre-login has no token → header
188
- // omitted → the server takes the anonymous path.
189
- const headers: Record<string, string> = { "content-type": "application/json" };
190
- const csrf = readCsrfToken();
191
- if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
192
- // kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
193
- const response = await fetch("/api/query", {
194
- method: "POST",
195
- headers,
196
- body: JSON.stringify({
197
- type: queryType,
198
- payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
199
- }),
200
- });
201
- if (!response.ok) {
202
- return {
203
- ok: false,
204
- status: response.status,
205
- body: await response.text().catch(() => response.statusText),
206
- };
207
- }
208
- const data: ByTenantResponse = await response.json();
209
- return { ok: true, blocks: data.data.blocks };
210
- }
211
-
212
- const TENANT_OVERRIDE_DENIED_REASON = "tenant_override_requires_system_admin";
213
-
214
- // The dispatcher accepts tenantIdOverride from a SystemAdmin only, and the
215
- // provider cannot see the session roles. Everyone else gets this exact denial
216
- // and reads their session tenant instead, which is the only tenant they may
217
- // edit anyway.
218
- function isTenantOverrideDenied(result: BlockListResult): boolean {
219
- return !result.ok && result.status === 403 && result.body.includes(TENANT_OVERRIDE_DENIED_REASON);
220
- }
221
-
222
- async function loadBlocks(
223
- queryType: string,
224
- tenantIdOverride: string | undefined,
225
- ): Promise<{ readonly blocks: readonly BlockSummary[]; readonly tenantIdOverride?: string }> {
226
- let result = await postBlockList(queryType, tenantIdOverride);
227
- let effectiveOverride = tenantIdOverride;
228
- if (tenantIdOverride !== undefined && isTenantOverrideDenied(result)) {
229
- result = await postBlockList(queryType, undefined);
230
- effectiveOverride = undefined;
231
- }
232
- if (!result.ok) {
233
- // 403 (CSRF/auth) or 5xx — explicit error instead of silent empty.
234
- throw new Error(`text-block load failed: ${result.status} ${result.body}`);
235
- }
236
- return {
237
- blocks: result.blocks,
238
- ...(effectiveOverride !== undefined && { tenantIdOverride: effectiveOverride }),
239
- };
240
- }
241
-
242
- // tenantIdOverride (SystemAdmin-only) lets an app point the content tree at a
243
- // tenant other than the session's — publicstatus seeds marketing/legal blocks
244
- // on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
245
- // Without a collection: the anonymous-capable by-tenant query, so a public
246
- // page keeps its content sidebar without a session. With one: that
247
- // collection's own list handler, which carries the collection's access rule.
248
- function makeTreeProvider(
249
- tenantIdOverride?: string,
250
- collection?: Pick<ContentCollectionSchema, "id" | "ownership">,
251
- ): TreeChildrenSubscribe {
252
- const collectionId = collection?.id;
253
- const queryType =
254
- collectionId === undefined
255
- ? TemplateResolverQueries.byTenant
256
- : collectionQueryName(collectionId, "list");
257
- // A per-user entry belongs to its author in the session tenant; pointing the
258
- // tree at another tenant would only hide the author's own entries.
259
- const requestedOverride = collection?.ownership === "user" ? undefined : tenantIdOverride;
260
- return () => (emit, emitError) => {
261
- let active = true;
262
- // Each denial logs a client-fault warning on the server, so a reload after a
263
- // save does not ask again once this subscription learned the answer.
264
- let override = requestedOverride;
265
- const load = (): void => {
266
- loadBlocks(queryType, override)
267
- .then(({ blocks, tenantIdOverride: effectiveOverride }) => {
268
- if (!active) return;
269
- override = effectiveOverride;
270
- // The app-side r.nav node IS the "Content" container → the provider's
271
- // children are the folders/leaves below it, not the wrapper.
272
- const content = groupBlocksByFolder(blocks, effectiveOverride, collectionId)[0];
273
- emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
274
- })
275
- .catch((e) => {
276
- if (!active) return;
277
- // Explicit error signal via emitError; ProviderBranch shows a banner
278
- // plus retry. Falls back to emit([]) for consumers without emitError.
279
- if (emitError) {
280
- emitError(e instanceof Error ? e : new Error(String(e)));
281
- } else {
282
- emit([]);
283
- }
284
- });
285
- };
286
- load();
287
- const unsubscribe = onTreeRefresh(collectionId ?? TEXT_BLOCK_TREE_KEY, load);
288
- return () => {
289
- active = false;
290
- unsubscribe();
291
- };
292
- };
293
- }
294
-
295
- type ContentCollectionSchema = NonNullable<FeatureSchema["contentCollections"]>[number];
296
-
297
- // A node without a collectionId (the hand-wired text-block tree) has no
298
- // collection to look up — contentFormat/variableSchema then fall back to
299
- // their own callers' defaults.
300
- function findCollection(
301
- features: readonly FeatureSchema[],
302
- collectionId?: string,
303
- ): ContentCollectionSchema | undefined {
304
- if (collectionId === undefined) return undefined;
305
- for (const feature of features) {
306
- const match = feature.contentCollections?.find((c) => c.id === collectionId);
307
- if (match !== undefined) return match;
308
- }
309
- return undefined;
310
- }
311
-
312
- function findContentFormat(
313
- features: readonly FeatureSchema[],
314
- collectionId?: string,
315
- ): ContentEditorFormat | undefined {
316
- return findCollection(features, collectionId)?.contentFormat;
317
- }
318
-
319
- // The collection's fixed variable names (ContentCollectionDefinition.
320
- // variableSchema) — the chip bar's data source and, keyed, the example
321
- // values the Preview substitutes in for `{{name}}`.
322
- function findVariableExamples(
323
- features: readonly FeatureSchema[],
324
- collectionId?: string,
325
- ): Readonly<Record<string, string>> {
326
- return findCollection(features, collectionId)?.variableSchema ?? {};
327
- }
328
-
329
- // Edit form: loads the current values, dispatches set on submit. Who may save
330
- // follows the server: a collection's set handler carries the collection's own
331
- // access rule (one rule for list, item and set), the public text-block pair
332
- // stays TenantAdmin/SystemAdmin. Other users see the form read-only.
333
- export function canWriteContent(
334
- collection: Pick<ContentCollectionSchema, "access"> | undefined,
335
- roles: readonly string[] | undefined,
336
- ): boolean {
337
- if (collection !== undefined) {
338
- return isUiAccessGranted(collection.access ?? DEFAULT_COLLECTION_ACCESS, roles);
339
- }
340
- return roles?.includes("TenantAdmin") === true || roles?.includes("SystemAdmin") === true;
341
- }
342
-
343
- type TextBlock = {
344
- readonly slug: string;
345
- readonly locale: string;
346
- readonly title: string | null;
347
- readonly content: string | null;
348
- readonly contentFormat: string;
349
- readonly folder: string | null;
350
- readonly modifiedAt: string | null;
351
- };
352
-
353
- // The collection's declared contentFormat ("plain" | "rich" | "markdown" |
354
- // "document", see ContentCollectionDefinition.contentFormat) uses a different vocabulary
355
- // than the stored entry's contentFormat ("plain" | "html" | "markdown" |
356
- // "mjml", see CONTENT_FORMATS) — "rich" and "document" always store HTML. Without this,
357
- // handleSave omitting contentFormat let the write schema's
358
- // contentFormatSchema.default("markdown") silently stamp every save as
359
- // markdown, clobbering e.g. a "plain" entry. Undefined (no collection, or
360
- // the collection didn't declare a format) keeps whatever was already saved
361
- // instead of guessing.
362
- // Exported for the unit test — resolveWriteContentFormat is a pure function.
363
- export function resolveWriteContentFormat(
364
- collectionContentFormat: string | undefined,
365
- loadedContentFormat: string | undefined,
366
- ): string | undefined {
367
- if (collectionContentFormat === "plain") return "plain";
368
- if (collectionContentFormat === "rich" || collectionContentFormat === "document") return "html";
369
- if (collectionContentFormat === "markdown") return "markdown";
370
- return loadedContentFormat;
371
- }
372
-
373
- type SetResponse = { readonly slug: string; readonly locale: string; readonly isNew: boolean };
374
-
375
- function TextBlockEditor({
376
- target,
377
- }: {
378
- readonly target: TargetRef;
379
- // onClose stays part of the resolver contract (the visual panel passes it)
380
- // but is unused: tree selection navigates, there is no close button.
381
- readonly onClose: () => void;
382
- }): ReactNode {
383
- // @cast-boundary visual-tree-args — TargetRef.args is erased to
384
- // Record<string, unknown>; the resolver knows the action shape (see the
385
- // treeActions.edit definition in feature.ts) and de-erases per action, the
386
- // same way event payloads do. Optional chaining absorbs missing fields so a
387
- // hand-edited URL cannot crash the editor.
388
- const args = target.args as
389
- | { slug?: string; locale?: string; collectionId?: string; tenantIdOverride?: string }
390
- | undefined;
391
- const slug = args?.slug ?? "";
392
- const locale = args?.locale ?? "";
393
- const tenantIdOverride = args?.tenantIdOverride;
394
- // Which collection the node came from. Absent → the public text-block pair,
395
- // which is what the hand-wired content tree uses.
396
- const collectionId = args?.collectionId;
397
-
398
- const { Form, Field, Input, Button, Banner, PageHeader } = usePrimitives();
399
- const dispatcher = useDispatcher();
400
- const user = useShellUser();
401
- const t = useTranslation();
402
- const contentEditorId = useId();
403
- const features = useAppFeatures();
404
- const contentFormat = findContentFormat(features, collectionId);
405
- const variableExamples = findVariableExamples(features, collectionId);
406
- const variables = Object.keys(variableExamples);
407
- const collection = findCollection(features, collectionId);
408
- const documentBlocks = documentBlocksForKind(collection?.kind);
409
- const ContentEditor = useContentEditor(contentFormat);
410
- const canWrite = canWriteContent(collection, user?.roles);
411
-
412
- // Same split as the tree provider: a node without a collection reads through
413
- // the public by-slug, a collection node through that collection's handlers.
414
- const readQuery =
415
- collectionId === undefined
416
- ? TemplateResolverQueries.bySlug
417
- : collectionQueryName(collectionId, "item");
418
- const writeHandler =
419
- collectionId === undefined ? TemplateResolverHandlers.set : collectionHandlerName(collectionId);
420
- const {
421
- data: loaded,
422
- loading,
423
- error: loadError,
424
- } = useQuery<TextBlock | null>(
425
- readQuery,
426
- { slug, locale, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
427
- { enabled: slug !== "" && locale !== "" },
428
- );
429
-
430
- const [title, setTitle] = useState("");
431
- const [content, setContent] = useState("");
432
- const [previewing, setPreviewing] = useState(false);
433
- const [submitting, setSubmitting] = useState(false);
434
- const [saveError, setSaveError] = useState<string | null>(null);
435
- const [savedMsg, setSavedMsg] = useState<string | null>(null);
436
-
437
- // Sync loaded data into the form state. loaded === null means the block does
438
- // not exist yet — empty form for the create flow.
439
- // kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
440
- useEffect(() => {
441
- if (loading) return;
442
- setTitle(loaded?.title ?? "");
443
- setContent(loaded?.content ?? "");
444
- setSaveError(null);
445
- setSavedMsg(null);
446
- }, [loading, loaded]);
447
-
448
- const handleSave = async (): Promise<void> => {
449
- setSubmitting(true);
450
- setSaveError(null);
451
- setSavedMsg(null);
452
- try {
453
- const resolvedContentFormat = resolveWriteContentFormat(contentFormat, loaded?.contentFormat);
454
- const result = await dispatcher.write<SetResponse>(writeHandler, {
455
- slug,
456
- locale,
457
- title,
458
- content: content.length > 0 ? content : null,
459
- folder: loaded?.folder ?? null,
460
- ...(resolvedContentFormat !== undefined && { contentFormat: resolvedContentFormat }),
461
- ...(tenantIdOverride !== undefined && { tenantIdOverride }),
462
- });
463
- if (result.isSuccess) {
464
- refreshTree(collectionId ?? TEXT_BLOCK_TREE_KEY);
465
- setSavedMsg(
466
- result.data.isNew
467
- ? t("template-resolver.editor.created")
468
- : t("template-resolver.editor.saved"),
469
- );
470
- return;
471
- }
472
- setSaveError(
473
- result.error.message ?? result.error.code ?? t("template-resolver.editor.saveFailed"),
474
- );
475
- } catch (e) {
476
- // Network blip / dispatcher throw — otherwise submitting stays true and
477
- // the save button is locked forever with no feedback.
478
- setSaveError(e instanceof Error ? e.message : t("template-resolver.editor.networkError"));
479
- } finally {
480
- setSubmitting(false);
481
- }
482
- };
483
-
484
- const onSubmit = (e?: FormEvent): void => {
485
- e?.preventDefault();
486
- void handleSave();
487
- };
488
-
489
- const disabled = submitting || loading || !canWrite;
490
- const headerTitle = title || slug || undefined;
491
-
492
- return (
493
- <>
494
- {/* The editor opens beside the routed screen, so without this the shell header keeps that screen's title. */}
495
- {PageHeader !== undefined && headerTitle !== undefined && <PageHeader title={headerTitle} />}
496
- <Form
497
- onSubmit={onSubmit}
498
- testId="text-block-editor"
499
- title={title || slug || EMPTY_VALUE_PLACEHOLDER}
500
- subtitle={locale !== "" ? `${slug} · (${locale})` : slug}
501
- actions={
502
- canWrite ? (
503
- <Button type="submit" loading={submitting} disabled={disabled}>
504
- {submitting
505
- ? t("template-resolver.editor.saving")
506
- : t("template-resolver.editor.save")}
507
- </Button>
508
- ) : undefined
509
- }
510
- >
511
- {loading && <Banner variant="loading">{t("template-resolver.editor.loading")}</Banner>}
512
- {loadError !== null && (
513
- <Banner variant="error">
514
- {t("template-resolver.editor.loadFailed")}: {loadError.code}
515
- </Banner>
516
- )}
517
- {!canWrite && !loading && (
518
- <Banner variant="info">{t("template-resolver.editor.readOnly")}</Banner>
519
- )}
520
- <Field id="text-block-title" label={t("template-resolver.editor.titleLabel")} required>
521
- <Input
522
- kind="text"
523
- id="text-block-title"
524
- name="text-block-title"
525
- value={title}
526
- onChange={setTitle}
527
- disabled={disabled}
528
- required
529
- />
530
- </Field>
531
- <Field
532
- id={contentEditorId}
533
- label={t("template-resolver.editor.contentLabel")}
534
- labelAppendix={
535
- <Button
536
- type="button"
537
- variant="secondary"
538
- size="sm"
539
- onClick={() => setPreviewing((p) => !p)}
540
- >
541
- {previewing ? t("kumiko.contentEditor.editMode") : t("kumiko.contentEditor.preview")}
542
- </Button>
543
- }
544
- >
545
- {previewing ? (
546
- <ContentPreview
547
- content={content}
548
- variables={variableExamples}
549
- contentFormat={contentFormat}
550
- />
551
- ) : (
552
- <DocumentBlocksContext.Provider value={documentBlocks}>
553
- <ContentEditor
554
- id={contentEditorId}
555
- value={content}
556
- onChange={setContent}
557
- variables={variables}
558
- readOnly={disabled}
559
- />
560
- </DocumentBlocksContext.Provider>
561
- )}
562
- </Field>
563
- {saveError !== null && <Banner variant="error">{saveError}</Banner>}
564
- {savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
565
- </Form>
566
- </>
567
- );
568
- }
6
+ import { TextBlockEditor } from "./text-block-editor.tsx";
569
7
 
570
8
  // `navId` = the QN of the r.nav({ provider: true }) node the app registers for
571
9
  // the content tree (e.g. "publicstatus:nav:content"). The app owns the node's
@@ -605,19 +43,13 @@ export function textBlocksClient(opts?: {
605
43
  }),
606
44
  resolvers: {
607
45
  "template-resolver:edit": TextBlockEditor,
46
+ "template-resolver:create": createCollectionEntryCreateForm(tenantId),
608
47
  },
609
- // The default for every "plain" collection (ai-prompt, mail without
610
- // an explicit rich override) — an app-registered "plain" editor on
611
- // another clientFeature still wins, last-wins same as columnRenderers.
48
+ // An app-registered editor for a format on another clientFeature still wins,
49
+ // last-wins same as columnRenderers.
612
50
  contentEditors: {
613
51
  plain: PlainContentEditor,
614
- rich: RichContentEditor,
615
52
  document: DocumentContentEditor,
616
- // ponytail: same textarea+chips as "plain" — markdown is stored as
617
- // text either way. Shares PlainContentEditor's fixed-DOM-id ceiling
618
- // (see its own file comment); swap for a live-preview widget or a
619
- // forwarded-ref variant if a collection actually needs one.
620
- markdown: PlainContentEditor,
621
53
  },
622
54
  translations: defaultTranslations,
623
55
  };