@cosmicdrift/kumiko-bundled-features 0.174.1 → 0.176.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 (45) hide show
  1. package/package.json +8 -10
  2. package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
  3. package/src/legal-pages/README.md +26 -25
  4. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
  5. package/src/legal-pages/feature.ts +40 -31
  6. package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
  7. package/src/legal-pages/web/client-plugin.ts +3 -3
  8. package/src/seo/__tests__/robots-txt.test.ts +21 -0
  9. package/src/seo/__tests__/seo.integration.test.ts +13 -13
  10. package/src/seo/robots-txt.ts +21 -1
  11. package/src/template-resolver/README.md +30 -1
  12. package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
  13. package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
  14. package/src/template-resolver/api.ts +11 -7
  15. package/src/template-resolver/constants.ts +22 -0
  16. package/src/template-resolver/feature.ts +16 -1
  17. package/src/template-resolver/handlers/by-slug.query.ts +54 -0
  18. package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
  19. package/src/template-resolver/handlers/set.write.ts +110 -0
  20. package/src/template-resolver/handlers/shared.ts +13 -2
  21. package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
  22. package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
  23. package/src/template-resolver/index.ts +7 -0
  24. package/src/template-resolver/qualified-names.ts +3 -0
  25. package/src/template-resolver/seeding.ts +126 -2
  26. package/src/template-resolver/table.ts +11 -2
  27. package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
  28. package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
  29. package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
  30. package/src/template-resolver/web/client-plugin.tsx +352 -0
  31. package/src/template-resolver/web/index.ts +8 -0
  32. package/src/text-content/README.md +0 -197
  33. package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
  34. package/src/text-content/api.ts +0 -86
  35. package/src/text-content/changes.json +0 -14
  36. package/src/text-content/constants.ts +0 -21
  37. package/src/text-content/feature.ts +0 -53
  38. package/src/text-content/handlers/by-slug.query.ts +0 -55
  39. package/src/text-content/handlers/by-tenant.query.ts +0 -60
  40. package/src/text-content/handlers/set.write.ts +0 -141
  41. package/src/text-content/index.ts +0 -14
  42. package/src/text-content/seeding.ts +0 -139
  43. package/src/text-content/table.ts +0 -53
  44. package/src/text-content/web/client-plugin.tsx +0 -391
  45. package/src/text-content/web/index.ts +0 -8
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- // mock.module eretzt imports für alle Konsumenten — statische imports
3
- // vor mock.module sehen die gemockte Version weil Bun am Loader-Level
4
- // intercepted. useShellUser ist hier ein Mock-Objekt.
2
+ // mock.module replaces imports for all consumers — static imports
3
+ // before mock.module still see the mocked version because Bun
4
+ // intercepts at the loader level. useShellUser is a mock object here.
5
5
  import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
6
6
  import {
7
7
  createStaticLocaleResolver,
@@ -11,7 +11,7 @@ import {
11
11
  import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
12
12
  import { act, fireEvent, render, screen } from "@testing-library/react";
13
13
  import type { ReactNode } from "react";
14
- import { textContentClient } from "../client-plugin";
14
+ import { textBlocksClient } from "../client-plugin";
15
15
 
16
16
  mock.module("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
17
17
  useShellUser: mock(),
@@ -24,9 +24,9 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
24
24
  useQuery: mock(() => ({
25
25
  data: {
26
26
  slug: "imprint",
27
- lang: "de",
27
+ locale: "de",
28
28
  title: "Impressum",
29
- body: "Inhalt",
29
+ content: "Inhalt",
30
30
  folder: "legal",
31
31
  },
32
32
  loading: false,
@@ -38,14 +38,14 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
38
38
  const { useDispatcher } = await import("@cosmicdrift/kumiko-renderer");
39
39
 
40
40
  const TARGET = {
41
- featureId: "text-content",
41
+ featureId: "template-resolver",
42
42
  action: "edit",
43
- args: { slug: "imprint", lang: "de" },
43
+ args: { slug: "imprint", locale: "de" },
44
44
  } as const;
45
45
 
46
46
  function getEditor() {
47
- const def = textContentClient();
48
- const Editor = def.resolvers?.["text-content:edit"];
47
+ const def = textBlocksClient();
48
+ const Editor = def.resolvers?.["template-resolver:edit"];
49
49
  if (!Editor) throw new Error("Editor not registered");
50
50
  return Editor;
51
51
  }
@@ -2,16 +2,16 @@ import { describe, expect, test } from "bun:test";
2
2
  import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { type BlockSummary, groupBlocksByFolder } from "../client-plugin";
4
4
 
5
- // TreeNode.children ist `readonly TreeNode[] | TreeChildrenSubscribe` —
6
- // im Provider-Output ist die Subscribe-Form nur für deferred-children
7
- // gedacht, groupBlocksByFolder produziert ausschließlich statische
8
- // Array-Children. TypeGuard statt as-Cast (Memory `[Type Assertions]`).
5
+ // TreeNode.children is `readonly TreeNode[] | TreeChildrenSubscribe` —
6
+ // in the provider output, the subscribe form is only meant for
7
+ // deferred children; groupBlocksByFolder produces exclusively static
8
+ // array children. TypeGuard instead of an as-cast (memory `[Type Assertions]`).
9
9
  function childrenArray(children: TreeNode["children"] | undefined): readonly TreeNode[] {
10
10
  if (!Array.isArray(children)) throw new Error("expected static children-array");
11
11
  return children;
12
12
  }
13
- // V.1.5d-Helper: groupBlocksByFolder gibt jetzt einen "Content"-Wrapper-
14
- // Folder zurück. Tests wollen den Inhalt UNTER dem Wrapper prüfen.
13
+ // V.1.5d helper: groupBlocksByFolder now returns a single "Content" wrapper
14
+ // folder. Tests want to check the content UNDER the wrapper.
15
15
  function inside(result: readonly TreeNode[]): readonly TreeNode[] {
16
16
  expect(result).toHaveLength(1);
17
17
  const wrapper = result[0];
@@ -19,19 +19,19 @@ function inside(result: readonly TreeNode[]): readonly TreeNode[] {
19
19
  expect(wrapper?.icon).toBe("folder");
20
20
  return childrenArray(wrapper?.children);
21
21
  }
22
- // Helper: BlockSummary mit defaults für die nicht-test-relevanten Felder.
22
+ // Helper: BlockSummary with defaults for the fields not relevant to the test.
23
23
  function block(opts: {
24
24
  slug: string;
25
25
  folder?: string | null;
26
- body?: string | null;
26
+ content?: string | null;
27
27
  title?: string;
28
28
  }): BlockSummary {
29
29
  return {
30
30
  slug: opts.slug,
31
- lang: "de",
31
+ locale: "de",
32
32
  title: opts.title ?? opts.slug,
33
- // Nicht ?? — null soll durchgereicht werden (state="stub"-Test).
34
- body: opts.body === undefined ? "irgendwas" : opts.body,
33
+ // Not ?? — null should be passed through (state="stub" test).
34
+ content: opts.content === undefined ? "irgendwas" : opts.content,
35
35
  folder: opts.folder === undefined ? null : opts.folder,
36
36
  updatedAt: "2026-05-19T00:00:00Z",
37
37
  };
@@ -48,9 +48,9 @@ describe("groupBlocksByFolder", () => {
48
48
  if (!root) return;
49
49
  expect(root.label).toBe("imprint");
50
50
  expect(root.target).toEqual({
51
- featureId: "text-content",
51
+ featureId: "template-resolver",
52
52
  action: "edit",
53
- args: { slug: "imprint", lang: "de" },
53
+ args: { slug: "imprint", locale: "de" },
54
54
  });
55
55
  expect(root.children).toBeUndefined();
56
56
  expect(root.icon).toBe("file");
@@ -71,7 +71,7 @@ describe("groupBlocksByFolder", () => {
71
71
  expect(child).toBeDefined();
72
72
  if (!child) return;
73
73
  expect(child.label).toBe("Hero");
74
- expect(child.target?.args).toEqual({ slug: "hero", lang: "de" });
74
+ expect(child.target?.args).toEqual({ slug: "hero", locale: "de" });
75
75
  });
76
76
  test("mehrere Slugs gleicher Folder → ein Folder mit mehreren children", () => {
77
77
  const nodes = inside(
@@ -116,12 +116,12 @@ describe("groupBlocksByFolder", () => {
116
116
  const folderLabels = nodes.map((n) => n.label);
117
117
  expect(folderLabels).toEqual(["apple", "mango", "zebra"]);
118
118
  });
119
- test('body=null → state="stub" (Designer-Hinweis dass Slug existiert aber leer)', () => {
120
- const nodes = inside(groupBlocksByFolder([block({ slug: "draft", body: null })]));
119
+ test('content=null → state="stub" (Designer-Hinweis dass Slug existiert aber leer)', () => {
120
+ const nodes = inside(groupBlocksByFolder([block({ slug: "draft", content: null })]));
121
121
  expect(nodes[0]?.state).toBe("stub");
122
122
  });
123
- test('body=string → state="filled"', () => {
124
- const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", body: "content" })]));
123
+ test('content=string → state="filled"', () => {
124
+ const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", content: "content" })]));
125
125
  expect(nodes[0]?.state).toBe("filled");
126
126
  });
127
127
  test("title leer → fallback auf slug als label", () => {
@@ -144,11 +144,11 @@ describe("groupBlocksByFolder", () => {
144
144
  const marketingChildren = childrenArray(marketingFolder?.children);
145
145
  expect(marketingChildren).toHaveLength(1);
146
146
  expect(marketingChildren[0]?.label).toBe("Hero");
147
- expect(marketingChildren[0]?.target?.args).toEqual({ slug: "hero", lang: "de" });
147
+ expect(marketingChildren[0]?.target?.args).toEqual({ slug: "hero", locale: "de" });
148
148
  });
149
149
  test("V.1.6a shared folder-prefix → ein gemeinsamer parent", () => {
150
- // Zwei blocks mit verschachteltem Pfad teilen die ersten Segmente.
151
- // page/hero + page/cta + page/marketing/banner → 1× page-folder mit
150
+ // Two blocks with a nested path share the first segments.
151
+ // page/hero + page/cta + page/marketing/banner → 1× page folder with
152
152
  // 3 children (2 leaves + 1 sub-folder).
153
153
  const nodes = inside(
154
154
  groupBlocksByFolder([
@@ -161,7 +161,7 @@ describe("groupBlocksByFolder", () => {
161
161
  const pageFolder = nodes[0];
162
162
  expect(pageFolder?.label).toBe("page");
163
163
  const pageChildren = childrenArray(pageFolder?.children);
164
- // Leaves first (Hero, CTA), dann sub-folder (marketing alphabetisch).
164
+ // Leaves first (Hero, CTA), then sub-folder (marketing alphabetically).
165
165
  expect(pageChildren.map((c) => c.label)).toEqual(["Hero", "CTA", "marketing"]);
166
166
  expect(pageChildren[2]?.icon).toBe("folder");
167
167
  const marketingChildren = childrenArray(pageChildren[2]?.children);
@@ -169,11 +169,11 @@ describe("groupBlocksByFolder", () => {
169
169
  expect(marketingChildren[0]?.label).toBe("Banner");
170
170
  });
171
171
  test("V.1.6a folder/leaf-collision: gleicher Name auf gleicher Ebene", () => {
172
- // Edge-Case (advisor-flagged): block mit folder=null, slug="page"
173
- // + block mit folder="page" → "page" existiert als Leaf-Root UND
174
- // als Folder. Beide bleiben sichtbar; Folder hat Chevron + Folder-
175
- // Icon, Leaf hat target + ist klickbar. Renderer-Pattern macht
176
- // visuell klar dass es zwei verschiedene Dinge sind.
172
+ // Edge case (advisor-flagged): block with folder=null, slug="page"
173
+ // + block with folder="page" → "page" exists both as a leaf-root AND
174
+ // as a folder. Both stay visible; the folder has a chevron + folder
175
+ // icon, the leaf has a target and is clickable. The renderer pattern
176
+ // makes it visually clear that these are two different things.
177
177
  const nodes = inside(
178
178
  groupBlocksByFolder([
179
179
  block({ slug: "page", folder: null, title: "Page-Root" }),
@@ -197,27 +197,27 @@ describe("groupBlocksByFolder", () => {
197
197
  "00000000-0000-4000-8000-000000000000",
198
198
  ),
199
199
  );
200
- // Root-Leaf trägt den Override.
200
+ // Root-leaf carries the override.
201
201
  expect(nodes[0]?.target?.args).toEqual({
202
202
  slug: "imprint",
203
- lang: "de",
203
+ locale: "de",
204
204
  tenantIdOverride: "00000000-0000-4000-8000-000000000000",
205
205
  });
206
- // Auch der Leaf im Folder (rekursiv durchgereicht).
206
+ // The leaf inside the folder too (passed through recursively).
207
207
  const folderChild = childrenArray(nodes[1]?.children)[0];
208
208
  expect(folderChild?.target?.args).toEqual({
209
209
  slug: "hero",
210
- lang: "de",
210
+ locale: "de",
211
211
  tenantIdOverride: "00000000-0000-4000-8000-000000000000",
212
212
  });
213
213
  });
214
214
  test("ohne tenantIdOverride bleiben die Args frei davon (Default: Session-Tenant)", () => {
215
215
  const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", folder: null })]));
216
- expect(nodes[0]?.target?.args).toEqual({ slug: "imprint", lang: "de" });
216
+ expect(nodes[0]?.target?.args).toEqual({ slug: "imprint", locale: "de" });
217
217
  });
218
218
  test("Wrapper-Folder 'Content' umschließt alle blocks", () => {
219
- // V.1.5d Wrapper-Convention: groupBlocksByFolder gibt EINEN Knoten
220
- // zurück (den Wrapper), Inhalt liegt eine Ebene tiefer.
219
+ // V.1.5d wrapper convention: groupBlocksByFolder returns ONE node
220
+ // (the wrapper), content sits one level deeper.
221
221
  const result = groupBlocksByFolder([block({ slug: "imprint" })]);
222
222
  expect(result).toHaveLength(1);
223
223
  const wrapper = result[0];
@@ -0,0 +1,352 @@
1
+ // @runtime client
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 { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
15
+ import type {
16
+ TargetRef,
17
+ TreeChildrenSubscribe,
18
+ TreeNode,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
21
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
22
+ import { type FormEvent, type ReactNode, useEffect, useState } from "react";
23
+ import { TemplateResolverHandlers, TemplateResolverQueries } from "../qualified-names";
24
+
25
+ // Exported for the unit test — groupBlocksByFolder is a pure function.
26
+ export type BlockSummary = {
27
+ readonly slug: string;
28
+ readonly locale: string;
29
+ readonly title: string | null;
30
+ readonly content: string | null;
31
+ readonly folder: string | null;
32
+ readonly updatedAt: string;
33
+ };
34
+
35
+ type ByTenantResponse = {
36
+ readonly data: { readonly blocks: readonly BlockSummary[] };
37
+ };
38
+
39
+ // Multi-level folder splitting: `folder` is a `/`-separated path, the tree
40
+ // splits per segment and creates nested folder nodes:
41
+ // block.folder = null → root leaf
42
+ // block.folder = "page" → folder page > leaf
43
+ // block.folder = "page/marketing" → folder page > folder marketing > leaf
44
+ //
45
+ // **Folder/leaf collision**: a block with `folder=null, slug="page"` and
46
+ // another with `folder="page"` both live on the same level — the folder with
47
+ // children plus a clickable leaf. Visually distinct (chevron + folder icon),
48
+ // no crash, no surprise.
49
+
50
+ type FolderNode = {
51
+ readonly leaves: TreeNode[];
52
+ readonly subFolders: Map<string, FolderNode>;
53
+ };
54
+
55
+ function newFolderNode(): FolderNode {
56
+ return { leaves: [], subFolders: new Map() };
57
+ }
58
+
59
+ function attachBlock(root: FolderNode, block: BlockSummary, tenantIdOverride?: string): void {
60
+ const leaf: TreeNode = {
61
+ label: block.title || block.slug,
62
+ icon: "file",
63
+ target: {
64
+ featureId: "template-resolver",
65
+ action: "edit",
66
+ // tenantIdOverride travels with the node → the editor's by-slug read +
67
+ // set write target the same tenant the tree was loaded from (SystemAdmin
68
+ // editing SYSTEM-tenant content). Omitted → session tenant.
69
+ args: {
70
+ slug: block.slug,
71
+ locale: block.locale,
72
+ ...(tenantIdOverride !== undefined && { tenantIdOverride }),
73
+ },
74
+ },
75
+ state: block.content ? "filled" : "stub",
76
+ };
77
+ if (block.folder === null) {
78
+ root.leaves.push(leaf);
79
+ return;
80
+ }
81
+ let cur = root;
82
+ for (const segment of block.folder.split("/")) {
83
+ let next = cur.subFolders.get(segment);
84
+ if (next === undefined) {
85
+ next = newFolderNode();
86
+ cur.subFolders.set(segment, next);
87
+ }
88
+ cur = next;
89
+ }
90
+ cur.leaves.push(leaf);
91
+ }
92
+
93
+ function renderFolderNode(node: FolderNode): TreeNode[] {
94
+ // Leaves first (root items), then folders alphabetically.
95
+ const result: TreeNode[] = [...node.leaves];
96
+ for (const folderName of [...node.subFolders.keys()].sort()) {
97
+ const subNode = node.subFolders.get(folderName);
98
+ if (subNode === undefined) continue;
99
+ result.push({
100
+ label: folderName,
101
+ icon: "folder",
102
+ state: "filled",
103
+ children: renderFolderNode(subNode),
104
+ });
105
+ }
106
+ return result;
107
+ }
108
+
109
+ export function groupBlocksByFolder(
110
+ blocks: readonly BlockSummary[],
111
+ tenantIdOverride?: string,
112
+ ): readonly TreeNode[] {
113
+ const root = newFolderNode();
114
+ for (const block of blocks) {
115
+ attachBlock(root, block, tenantIdOverride);
116
+ }
117
+ const rendered = renderFolderNode(root);
118
+ if (rendered.length === 0) return [];
119
+ return [
120
+ {
121
+ label: "Content",
122
+ icon: "folder",
123
+ state: "filled",
124
+ children: rendered,
125
+ },
126
+ ];
127
+ }
128
+
129
+ // tenantIdOverride (SystemAdmin-only) lets an app point the content tree at a
130
+ // tenant other than the session's — publicstatus seeds marketing/legal blocks
131
+ // on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
132
+ function makeTreeProvider(tenantIdOverride?: string): TreeChildrenSubscribe {
133
+ return () => (emit, emitError) => {
134
+ // CSRF header is mandatory on authenticated requests (auth-middleware
135
+ // double-submit pattern). Anonymous/pre-login has no token → header
136
+ // omitted → the server takes the anonymous path.
137
+ const headers: Record<string, string> = { "content-type": "application/json" };
138
+ const csrf = readCsrfToken();
139
+ if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
140
+ fetch("/api/query", {
141
+ method: "POST",
142
+ headers,
143
+ body: JSON.stringify({
144
+ type: TemplateResolverQueries.byTenant,
145
+ payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
146
+ }),
147
+ })
148
+ .then(async (r) => {
149
+ if (!r.ok) {
150
+ // 403 (CSRF/auth) or 5xx — explicit error instead of silent empty.
151
+ const text = await r.text().catch(() => r.statusText);
152
+ throw new Error(`text-block load failed: ${r.status} ${text}`);
153
+ }
154
+ return r.json();
155
+ })
156
+ .then((data: ByTenantResponse) => {
157
+ // The app-side r.nav node IS the "Content" container → the provider's
158
+ // children are the folders/leaves below it, not the wrapper.
159
+ const content = groupBlocksByFolder(data.data.blocks, tenantIdOverride)[0];
160
+ emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
161
+ })
162
+ .catch((e) => {
163
+ // Explicit error signal via emitError; ProviderBranch shows a banner
164
+ // plus retry. Falls back to emit([]) for consumers without emitError.
165
+ if (emitError) {
166
+ emitError(e instanceof Error ? e : new Error(String(e)));
167
+ } else {
168
+ emit([]);
169
+ }
170
+ });
171
+ return () => {};
172
+ };
173
+ }
174
+
175
+ // Edit form: loads the current values via by-slug, lets TenantAdmin and
176
+ // SystemAdmin edit title + content, dispatches set on submit. Other users see
177
+ // the form read-only with a hint banner — write permission stays opinionated
178
+ // TenantAdmin-only, apps widen it via dual-role mapping if they want to.
179
+
180
+ type TextBlock = {
181
+ readonly slug: string;
182
+ readonly locale: string;
183
+ readonly title: string | null;
184
+ readonly content: string | null;
185
+ readonly folder: string | null;
186
+ readonly updatedAt: string;
187
+ };
188
+
189
+ type SetResponse = { readonly slug: string; readonly locale: string; readonly isNew: boolean };
190
+
191
+ function TextBlockEditor({
192
+ target,
193
+ }: {
194
+ readonly target: TargetRef;
195
+ // onClose stays part of the resolver contract (the visual panel passes it)
196
+ // but is unused: tree selection navigates, there is no close button.
197
+ readonly onClose: () => void;
198
+ }): ReactNode {
199
+ // @cast-boundary visual-tree-args — TargetRef.args is erased to
200
+ // Record<string, unknown>; the resolver knows the action shape (see the
201
+ // treeActions.edit definition in feature.ts) and de-erases per action, the
202
+ // same way event payloads do. Optional chaining absorbs missing fields so a
203
+ // hand-edited URL cannot crash the editor.
204
+ const args = target.args as
205
+ | { slug?: string; locale?: string; tenantIdOverride?: string }
206
+ | undefined;
207
+ const slug = args?.slug ?? "";
208
+ const locale = args?.locale ?? "";
209
+ const tenantIdOverride = args?.tenantIdOverride;
210
+
211
+ const { Form, Field, Input, Button, Banner } = usePrimitives();
212
+ const dispatcher = useDispatcher();
213
+ const user = useShellUser();
214
+ const canWrite =
215
+ user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
216
+
217
+ const {
218
+ data: loaded,
219
+ loading,
220
+ error: loadError,
221
+ } = useQuery<TextBlock | null>(
222
+ TemplateResolverQueries.bySlug,
223
+ { slug, locale, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
224
+ { enabled: slug !== "" && locale !== "" },
225
+ );
226
+
227
+ const [title, setTitle] = useState("");
228
+ const [content, setContent] = useState("");
229
+ const [submitting, setSubmitting] = useState(false);
230
+ const [saveError, setSaveError] = useState<string | null>(null);
231
+ const [savedMsg, setSavedMsg] = useState<string | null>(null);
232
+
233
+ // Sync loaded data into the form state. loaded === null means the block does
234
+ // not exist yet — empty form for the create flow.
235
+ useEffect(() => {
236
+ if (loading) return;
237
+ setTitle(loaded?.title ?? "");
238
+ setContent(loaded?.content ?? "");
239
+ setSaveError(null);
240
+ setSavedMsg(null);
241
+ }, [loading, loaded]);
242
+
243
+ const handleSave = async (): Promise<void> => {
244
+ setSubmitting(true);
245
+ setSaveError(null);
246
+ setSavedMsg(null);
247
+ try {
248
+ const result = await dispatcher.write<SetResponse>(TemplateResolverHandlers.set, {
249
+ slug,
250
+ locale,
251
+ title,
252
+ content: content.length > 0 ? content : null,
253
+ folder: loaded?.folder ?? null,
254
+ ...(tenantIdOverride !== undefined && { tenantIdOverride }),
255
+ });
256
+ if (result.isSuccess) {
257
+ setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
258
+ return;
259
+ }
260
+ setSaveError(result.error.message ?? result.error.code ?? "Speichern fehlgeschlagen.");
261
+ } catch (e) {
262
+ // Network blip / dispatcher throw — otherwise submitting stays true and
263
+ // the save button is locked forever with no feedback.
264
+ setSaveError(e instanceof Error ? e.message : "Netzwerkfehler beim Speichern.");
265
+ } finally {
266
+ setSubmitting(false);
267
+ }
268
+ };
269
+
270
+ const onSubmit = (e?: FormEvent): void => {
271
+ e?.preventDefault();
272
+ void handleSave();
273
+ };
274
+
275
+ const disabled = submitting || loading || !canWrite;
276
+
277
+ return (
278
+ <Form
279
+ onSubmit={onSubmit}
280
+ testId="text-block-editor"
281
+ title={title || slug || "—"}
282
+ subtitle={locale !== "" ? `${slug} · (${locale})` : slug}
283
+ actions={
284
+ canWrite ? (
285
+ <Button type="submit" loading={submitting} disabled={disabled}>
286
+ {submitting ? "Speichern…" : "Speichern"}
287
+ </Button>
288
+ ) : undefined
289
+ }
290
+ >
291
+ {loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
292
+ {loadError !== null && (
293
+ <Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
294
+ )}
295
+ {!canWrite && !loading && (
296
+ <Banner variant="info">
297
+ Read-only — TenantAdmin- oder SystemAdmin-Rolle f&uuml;r &Auml;nderungen erforderlich.
298
+ </Banner>
299
+ )}
300
+ <Field id="text-block-title" label="Titel" required>
301
+ <Input
302
+ kind="text"
303
+ id="text-block-title"
304
+ name="text-block-title"
305
+ value={title}
306
+ onChange={setTitle}
307
+ disabled={disabled}
308
+ required
309
+ />
310
+ </Field>
311
+ <Field id="text-block-content" label="Inhalt">
312
+ <Input
313
+ kind="textarea"
314
+ id="text-block-content"
315
+ name="text-block-content"
316
+ value={content}
317
+ onChange={setContent}
318
+ disabled={disabled}
319
+ rows={14}
320
+ />
321
+ </Field>
322
+ {saveError !== null && <Banner variant="error">{saveError}</Banner>}
323
+ {savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
324
+ </Form>
325
+ );
326
+ }
327
+
328
+ // `navId` = the QN of the r.nav({ provider: true }) node the app registers for
329
+ // the content tree (e.g. "publicstatus:nav:content"). The app owns the node's
330
+ // label/icon/access, the feature only supplies children plus the editor.
331
+ // Without navId: resolver only, no sidebar node (server-only consumers such as
332
+ // money-horse leak nothing).
333
+ // `tenantId` (SystemAdmin-only): which tenant the tree and editor serve —
334
+ // SYSTEM_TENANT_ID for apps that seed marketing/legal there. Omit for the
335
+ // session tenant (default, no cross-tenant access).
336
+ export function textBlocksClient(opts?: {
337
+ readonly navId?: string;
338
+ readonly tenantId?: string;
339
+ }): ClientFeatureDefinition {
340
+ const navId = opts?.navId;
341
+ return {
342
+ name: "template-resolver",
343
+ ...(navId !== undefined && {
344
+ navProviders: { [navId]: makeTreeProvider(opts?.tenantId) },
345
+ // SSE refresh: every template-resource event re-fires the provider.
346
+ navEntities: { [navId]: ["template-resource"] },
347
+ }),
348
+ resolvers: {
349
+ "template-resolver:edit": TextBlockEditor,
350
+ },
351
+ };
352
+ }
@@ -0,0 +1,8 @@
1
+ // @runtime client
2
+ // Public exports for the browser side of template-resolver. Consumed through
3
+ // the sub-path export `@cosmicdrift/kumiko-bundled-features/template-resolver/web`
4
+ // — the server side (defineFeature) lives in
5
+ // `@cosmicdrift/kumiko-bundled-features/template-resolver` and has no React or
6
+ // DOM deps.
7
+
8
+ export { textBlocksClient } from "./client-plugin";