@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.
- package/package.json +8 -10
- package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
- package/src/legal-pages/README.md +26 -25
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
- package/src/legal-pages/feature.ts +40 -31
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
- package/src/legal-pages/web/client-plugin.ts +3 -3
- package/src/seo/__tests__/robots-txt.test.ts +21 -0
- package/src/seo/__tests__/seo.integration.test.ts +13 -13
- package/src/seo/robots-txt.ts +21 -1
- package/src/template-resolver/README.md +30 -1
- package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
- package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
- package/src/template-resolver/api.ts +11 -7
- package/src/template-resolver/constants.ts +22 -0
- package/src/template-resolver/feature.ts +16 -1
- package/src/template-resolver/handlers/by-slug.query.ts +54 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
- package/src/template-resolver/handlers/set.write.ts +110 -0
- package/src/template-resolver/handlers/shared.ts +13 -2
- package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
- package/src/template-resolver/index.ts +7 -0
- package/src/template-resolver/qualified-names.ts +3 -0
- package/src/template-resolver/seeding.ts +126 -2
- package/src/template-resolver/table.ts +11 -2
- package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
- package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
- package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
- package/src/template-resolver/web/client-plugin.tsx +352 -0
- package/src/template-resolver/web/index.ts +8 -0
- package/src/text-content/README.md +0 -197
- package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
- package/src/text-content/api.ts +0 -86
- package/src/text-content/changes.json +0 -14
- package/src/text-content/constants.ts +0 -21
- package/src/text-content/feature.ts +0 -53
- package/src/text-content/handlers/by-slug.query.ts +0 -55
- package/src/text-content/handlers/by-tenant.query.ts +0 -60
- package/src/text-content/handlers/set.write.ts +0 -141
- package/src/text-content/index.ts +0 -14
- package/src/text-content/seeding.ts +0 -139
- package/src/text-content/table.ts +0 -53
- package/src/text-content/web/client-plugin.tsx +0 -391
- package/src/text-content/web/index.ts +0 -8
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
// @runtime client
|
|
2
|
-
// Client-Feature-Factory für text-content Visual-Tree. Wird vom App-Code
|
|
3
|
-
// in createKumikoApp({ clientFeatures: [textContentClient()] }) eingehängt
|
|
4
|
-
// und liefert den treeProvider der Text-Blocks aus der by-tenant Query
|
|
5
|
-
// lädt, nach `folder`-Field gruppiert und als TreeNode[] emitted.
|
|
6
|
-
//
|
|
7
|
-
// **Folder-Gruppierung V.1.4**: Block.folder !== null → Knoten landet
|
|
8
|
-
// unter einem Container-Knoten mit Label folder. folder === null →
|
|
9
|
-
// Top-Level (root-node). Slug bleibt kebab-only validiert. Beispiele:
|
|
10
|
-
// - folder="page", slug="hero" → Folder "page", child "hero"
|
|
11
|
-
// - folder=null, slug="imprint" → root-node "imprint"
|
|
12
|
-
//
|
|
13
|
-
// **State**: TreeNode.state = "filled" wenn body gesetzt ist, sonst
|
|
14
|
-
// "stub" (hellgrau, Designer-Hinweis dass Slug existiert aber leer ist).
|
|
15
|
-
//
|
|
16
|
-
// **Fetch statt Subscribe**: V.1.4 ist Fetch-once beim Mount. Unsubscribe
|
|
17
|
-
// ist no-op. V.1.5+ kann SSE-driven Re-Emit einbauen wenn text-block-
|
|
18
|
-
// updated-Events propagiert werden (Stale-Tree-Fix nach save).
|
|
19
|
-
|
|
20
|
-
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
21
|
-
import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
22
|
-
import type {
|
|
23
|
-
TargetRef,
|
|
24
|
-
TreeChildrenSubscribe,
|
|
25
|
-
TreeNode,
|
|
26
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
-
import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
|
|
28
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
29
|
-
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
|
30
|
-
import { TextContentHandlers, TextContentQueries } from "../constants";
|
|
31
|
-
|
|
32
|
-
// Exported für Unit-Test (groupBlocksByFolder ist pure-function ohne
|
|
33
|
-
// fetch/DOM). Public-API für externe Konsumenten ist nicht intendiert —
|
|
34
|
-
// sub-path-Export endet bei textContentClient().
|
|
35
|
-
export type BlockSummary = {
|
|
36
|
-
readonly slug: string;
|
|
37
|
-
readonly lang: string;
|
|
38
|
-
readonly title: string;
|
|
39
|
-
readonly body: string | null;
|
|
40
|
-
readonly folder: string | null;
|
|
41
|
-
readonly updatedAt: string;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
type ByTenantResponse = {
|
|
45
|
-
readonly data: { readonly blocks: readonly BlockSummary[] };
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// V.1.6a Multi-level Folder-Splitting: `folder` ist ein `/`-separierter
|
|
49
|
-
// Pfad (z.B. "page/marketing"). Der Tree splittet pro Segment und
|
|
50
|
-
// erzeugt nested Folder-Knoten:
|
|
51
|
-
// block.folder = null → root-leaf
|
|
52
|
-
// block.folder = "page" → 📁 page > leaf
|
|
53
|
-
// block.folder = "page/marketing" → 📁 page > 📁 marketing > leaf
|
|
54
|
-
//
|
|
55
|
-
// Walk-or-create-pattern: pro Block durch die Folder-Hierarchie laufen,
|
|
56
|
-
// Folder-Knoten anlegen wo nötig, Leaf am Ende anhängen. Folders pro
|
|
57
|
-
// Ebene alphabetisch sortiert (deterministisch).
|
|
58
|
-
//
|
|
59
|
-
// **Folder/Leaf-Collision** (advisor-Edge-Case): wenn ein Block
|
|
60
|
-
// `folder=null, slug="page"` UND ein anderer `folder="page", slug=...`
|
|
61
|
-
// existieren, leben beide an derselben Ebene — der Folder mit children
|
|
62
|
-
// + ein klickbarer Leaf "page" als root. Visual unterscheidbar (Folder
|
|
63
|
-
// hat Chevron + Folder-Icon, Leaf nicht). Kein Crash, keine Surprise.
|
|
64
|
-
//
|
|
65
|
-
// **V.1.5d Wrapper-Folder "Content"**: alle text-content-Blocks landen
|
|
66
|
-
// unter einem expliziten Top-Level-Folder. Empty-Tree → kein Wrapper.
|
|
67
|
-
|
|
68
|
-
type FolderNode = {
|
|
69
|
-
readonly leaves: TreeNode[];
|
|
70
|
-
readonly subFolders: Map<string, FolderNode>;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
function newFolderNode(): FolderNode {
|
|
74
|
-
return { leaves: [], subFolders: new Map() };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function attachBlock(root: FolderNode, block: BlockSummary, tenantIdOverride?: string): void {
|
|
78
|
-
const leaf: TreeNode = {
|
|
79
|
-
label: block.title || block.slug,
|
|
80
|
-
icon: "file",
|
|
81
|
-
target: {
|
|
82
|
-
featureId: "text-content",
|
|
83
|
-
action: "edit",
|
|
84
|
-
// tenantIdOverride travels with the node → the editor's by-slug read +
|
|
85
|
-
// set write target the same tenant the tree was loaded from (SystemAdmin
|
|
86
|
-
// editing SYSTEM-tenant content). Omitted → session tenant.
|
|
87
|
-
args: {
|
|
88
|
-
slug: block.slug,
|
|
89
|
-
lang: block.lang,
|
|
90
|
-
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
state: block.body ? "filled" : "stub",
|
|
94
|
-
};
|
|
95
|
-
if (block.folder === null) {
|
|
96
|
-
root.leaves.push(leaf);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
let cur = root;
|
|
100
|
-
for (const segment of block.folder.split("/")) {
|
|
101
|
-
let next = cur.subFolders.get(segment);
|
|
102
|
-
if (next === undefined) {
|
|
103
|
-
next = newFolderNode();
|
|
104
|
-
cur.subFolders.set(segment, next);
|
|
105
|
-
}
|
|
106
|
-
cur = next;
|
|
107
|
-
}
|
|
108
|
-
cur.leaves.push(leaf);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function renderFolderNode(node: FolderNode): TreeNode[] {
|
|
112
|
-
// Leaves first (root-items), dann Folders alphabetisch.
|
|
113
|
-
const result: TreeNode[] = [...node.leaves];
|
|
114
|
-
for (const folderName of [...node.subFolders.keys()].sort()) {
|
|
115
|
-
const subNode = node.subFolders.get(folderName);
|
|
116
|
-
if (subNode === undefined) continue;
|
|
117
|
-
result.push({
|
|
118
|
-
label: folderName,
|
|
119
|
-
icon: "folder",
|
|
120
|
-
state: "filled",
|
|
121
|
-
children: renderFolderNode(subNode),
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
return result;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function groupBlocksByFolder(
|
|
128
|
-
blocks: readonly BlockSummary[],
|
|
129
|
-
tenantIdOverride?: string,
|
|
130
|
-
): readonly TreeNode[] {
|
|
131
|
-
const root = newFolderNode();
|
|
132
|
-
for (const block of blocks) {
|
|
133
|
-
attachBlock(root, block, tenantIdOverride);
|
|
134
|
-
}
|
|
135
|
-
const rendered = renderFolderNode(root);
|
|
136
|
-
if (rendered.length === 0) return [];
|
|
137
|
-
return [
|
|
138
|
-
{
|
|
139
|
-
label: "Content",
|
|
140
|
-
icon: "folder",
|
|
141
|
-
state: "filled",
|
|
142
|
-
children: rendered,
|
|
143
|
-
},
|
|
144
|
-
];
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// tenantIdOverride (SystemAdmin-only) lets an app point the Content tree at a
|
|
148
|
-
// tenant other than the session's — publicstatus seeds marketing/legal blocks
|
|
149
|
-
// on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
|
|
150
|
-
function makeTreeProvider(tenantIdOverride?: string): TreeChildrenSubscribe {
|
|
151
|
-
return () => (emit, emitError) => {
|
|
152
|
-
// CSRF-Header bei authenticated requests pflicht (auth-middleware
|
|
153
|
-
// double-submit pattern). Anonymous/Pre-Login wäre csrf-token=undefined
|
|
154
|
-
// → header weggelassen → server lässt die anonymous-Variante durch.
|
|
155
|
-
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
156
|
-
const csrf = readCsrfToken();
|
|
157
|
-
if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
|
|
158
|
-
fetch("/api/query", {
|
|
159
|
-
method: "POST",
|
|
160
|
-
headers,
|
|
161
|
-
body: JSON.stringify({
|
|
162
|
-
type: TextContentQueries.byTenant,
|
|
163
|
-
payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
|
|
164
|
-
}),
|
|
165
|
-
})
|
|
166
|
-
.then(async (r) => {
|
|
167
|
-
if (!r.ok) {
|
|
168
|
-
// 403 (CSRF/auth) oder 5xx — explicit error statt silent empty.
|
|
169
|
-
const text = await r.text().catch(() => r.statusText);
|
|
170
|
-
throw new Error(`text-content load failed: ${r.status} ${text}`);
|
|
171
|
-
}
|
|
172
|
-
return r.json();
|
|
173
|
-
})
|
|
174
|
-
.then((data: ByTenantResponse) => {
|
|
175
|
-
// Der App-seitige r.nav-Knoten IST der "Content"-Container → die
|
|
176
|
-
// Provider-Kinder sind die Folder/Leaves darunter, nicht der Wrapper.
|
|
177
|
-
const content = groupBlocksByFolder(data.data.blocks, tenantIdOverride)[0];
|
|
178
|
-
emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
|
|
179
|
-
})
|
|
180
|
-
.catch((e) => {
|
|
181
|
-
// V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
|
|
182
|
-
// Banner + Retry-Button. Fallback auf emit([]) wenn der Consumer
|
|
183
|
-
// kein emitError unterstützt (Tests etc.).
|
|
184
|
-
if (emitError) {
|
|
185
|
-
emitError(e instanceof Error ? e : new Error(String(e)));
|
|
186
|
-
} else {
|
|
187
|
-
emit([]);
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
return () => {};
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// V.1.3 echte Edit-Form: lädt aktuelle Werte via by-slug-query, lässt
|
|
195
|
-
// TenantAdmin/SystemAdmin title + body editieren, dispatcht set-write
|
|
196
|
-
// bei Submit. Non-Admin-User sehen die Form read-only mit Hint-Banner —
|
|
197
|
-
// das ist der Kumiko-Weg (Memory `[Sicherheit > Convenience]`: write-
|
|
198
|
-
// permission bleibt opinionated TenantAdmin-only, App-Roles erweitern
|
|
199
|
-
// per Dual-Role-Mapping wenn gewollt).
|
|
200
|
-
//
|
|
201
|
-
// **Stale-Tree-Caveat (V.1.4-Followup)**: TreeProvider ist fetch-once.
|
|
202
|
-
// Nach erfolgreichem Save flippt der visual state="stub"→"filled" in
|
|
203
|
-
// der Sidebar NICHT, bis der User den Workspace re-mountet. Editor selbst
|
|
204
|
-
// ist konsistent (by-slug-Re-Read würde aktuelle Werte liefern, hier
|
|
205
|
-
// aber ohne extra-Re-Query — savedAt-Banner signalisiert success und
|
|
206
|
-
// der lokale Form-State trägt die neuen Werte). Echte Lösung: SSE-
|
|
207
|
-
// driven Tree-Refresh oder explicit cache-bust nach set-write.
|
|
208
|
-
|
|
209
|
-
type TextBlock = {
|
|
210
|
-
readonly slug: string;
|
|
211
|
-
readonly lang: string;
|
|
212
|
-
readonly title: string;
|
|
213
|
-
readonly body: string | null;
|
|
214
|
-
readonly folder: string | null;
|
|
215
|
-
readonly updatedAt: string;
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
type SetResponse = { readonly slug: string; readonly lang: string; readonly isNew: boolean };
|
|
219
|
-
|
|
220
|
-
function TextContentEditor({
|
|
221
|
-
target,
|
|
222
|
-
}: {
|
|
223
|
-
readonly target: TargetRef;
|
|
224
|
-
// onClose bleibt Teil des Resolver-Contracts (Visual-Panel liefert es),
|
|
225
|
-
// wird aber nicht mehr gebraucht: die Tree-Auswahl navigiert, kein
|
|
226
|
-
// Schließen-Button mehr. Nicht destrukturiert → kein unused binding.
|
|
227
|
-
readonly onClose: () => void;
|
|
228
|
-
}): ReactNode {
|
|
229
|
-
// @cast-boundary visual-tree-args — TargetRef.args ist erased zu
|
|
230
|
-
// Record<string, unknown>; der Resolver kennt das Action-Shape (siehe
|
|
231
|
-
// treeActions.edit-Definition unten) und de-erased pro Action analog
|
|
232
|
-
// zu Event-Payloads. Optional-Chain absorbiert fehlende Felder ohne
|
|
233
|
-
// throw, damit der Editor auch bei manuellem URL-Tampering nicht
|
|
234
|
-
// crasht (TargetRef könnte aus old localStorage / URL-State stammen).
|
|
235
|
-
const args = target.args as
|
|
236
|
-
| { slug?: string; lang?: string; tenantIdOverride?: string }
|
|
237
|
-
| undefined;
|
|
238
|
-
const slug = args?.slug ?? "";
|
|
239
|
-
const lang = args?.lang ?? "";
|
|
240
|
-
const tenantIdOverride = args?.tenantIdOverride;
|
|
241
|
-
|
|
242
|
-
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
243
|
-
const dispatcher = useDispatcher();
|
|
244
|
-
const user = useShellUser();
|
|
245
|
-
const canWrite =
|
|
246
|
-
user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
|
|
247
|
-
|
|
248
|
-
// Load existing block via by-slug-query. Result ist entweder TextBlock
|
|
249
|
-
// oder null (slug existiert nicht). useQuery returnt `data: T | null`,
|
|
250
|
-
// initial-loading: data=null + loading=true.
|
|
251
|
-
const {
|
|
252
|
-
data: loaded,
|
|
253
|
-
loading,
|
|
254
|
-
error: loadError,
|
|
255
|
-
} = useQuery<TextBlock | null>(
|
|
256
|
-
TextContentQueries.bySlug,
|
|
257
|
-
{ slug, lang, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
|
|
258
|
-
{ enabled: slug !== "" && lang !== "" },
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
// Form-State unabhängig vom geladenen Block (User-Edits sollen
|
|
262
|
-
// erhalten bleiben wenn loaded-data sich ändert). sync nur initial
|
|
263
|
-
// oder wenn target.slug+lang wechselt.
|
|
264
|
-
const [title, setTitle] = useState("");
|
|
265
|
-
const [body, setBody] = useState("");
|
|
266
|
-
const [submitting, setSubmitting] = useState(false);
|
|
267
|
-
const [saveError, setSaveError] = useState<string | null>(null);
|
|
268
|
-
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
|
269
|
-
|
|
270
|
-
// Sync loaded data → form state. Trigger sobald loaded-shape eindeutig
|
|
271
|
-
// ist (data ≠ undefined). loaded === null heißt "Block existiert noch
|
|
272
|
-
// nicht" — leere Form für create-flow.
|
|
273
|
-
useEffect(() => {
|
|
274
|
-
if (loading) return;
|
|
275
|
-
setTitle(loaded?.title ?? "");
|
|
276
|
-
setBody(loaded?.body ?? "");
|
|
277
|
-
setSaveError(null);
|
|
278
|
-
setSavedMsg(null);
|
|
279
|
-
}, [loading, loaded]);
|
|
280
|
-
|
|
281
|
-
const handleSave = async (): Promise<void> => {
|
|
282
|
-
setSubmitting(true);
|
|
283
|
-
setSaveError(null);
|
|
284
|
-
setSavedMsg(null);
|
|
285
|
-
try {
|
|
286
|
-
const result = await dispatcher.write<SetResponse>(TextContentHandlers.set, {
|
|
287
|
-
slug,
|
|
288
|
-
lang,
|
|
289
|
-
title,
|
|
290
|
-
body: body.length > 0 ? body : null,
|
|
291
|
-
folder: loaded?.folder ?? null,
|
|
292
|
-
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
293
|
-
});
|
|
294
|
-
if (result.isSuccess) {
|
|
295
|
-
setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
setSaveError(result.error.message ?? result.error.code ?? "Speichern fehlgeschlagen.");
|
|
299
|
-
} catch (e) {
|
|
300
|
-
// Network-blip / dispatcher-throw — sonst bleibt submitting=true,
|
|
301
|
-
// Save-Button locked-forever, User klickt repeat ohne Feedback.
|
|
302
|
-
// Generic message reicht: konkreter Recovery-Pfad ist Retry.
|
|
303
|
-
setSaveError(e instanceof Error ? e.message : "Netzwerkfehler beim Speichern.");
|
|
304
|
-
} finally {
|
|
305
|
-
setSubmitting(false);
|
|
306
|
-
}
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
const onSubmit = (e?: FormEvent): void => {
|
|
310
|
-
e?.preventDefault();
|
|
311
|
-
void handleSave();
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
const disabled = submitting || loading || !canWrite;
|
|
315
|
-
|
|
316
|
-
return (
|
|
317
|
-
<Form
|
|
318
|
-
onSubmit={onSubmit}
|
|
319
|
-
testId="text-content-editor"
|
|
320
|
-
title={title || slug || "—"}
|
|
321
|
-
subtitle={lang !== "" ? `${slug} · (${lang})` : slug}
|
|
322
|
-
actions={
|
|
323
|
-
canWrite ? (
|
|
324
|
-
<Button type="submit" loading={submitting} disabled={disabled}>
|
|
325
|
-
{submitting ? "Speichern…" : "Speichern"}
|
|
326
|
-
</Button>
|
|
327
|
-
) : undefined
|
|
328
|
-
}
|
|
329
|
-
>
|
|
330
|
-
{loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
|
|
331
|
-
{loadError !== null && (
|
|
332
|
-
<Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
|
|
333
|
-
)}
|
|
334
|
-
{!canWrite && !loading && (
|
|
335
|
-
<Banner variant="info">
|
|
336
|
-
Read-only — TenantAdmin- oder SystemAdmin-Rolle für Änderungen erforderlich.
|
|
337
|
-
</Banner>
|
|
338
|
-
)}
|
|
339
|
-
<Field id="text-content-title" label="Titel" required>
|
|
340
|
-
<Input
|
|
341
|
-
kind="text"
|
|
342
|
-
id="text-content-title"
|
|
343
|
-
name="text-content-title"
|
|
344
|
-
value={title}
|
|
345
|
-
onChange={setTitle}
|
|
346
|
-
disabled={disabled}
|
|
347
|
-
required
|
|
348
|
-
/>
|
|
349
|
-
</Field>
|
|
350
|
-
<Field id="text-content-body" label="Inhalt">
|
|
351
|
-
<Input
|
|
352
|
-
kind="textarea"
|
|
353
|
-
id="text-content-body"
|
|
354
|
-
name="text-content-body"
|
|
355
|
-
value={body}
|
|
356
|
-
onChange={setBody}
|
|
357
|
-
disabled={disabled}
|
|
358
|
-
rows={14}
|
|
359
|
-
/>
|
|
360
|
-
</Field>
|
|
361
|
-
{saveError !== null && <Banner variant="error">{saveError}</Banner>}
|
|
362
|
-
{savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
|
|
363
|
-
</Form>
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// `navId` = die QN des r.nav({ provider: true })-Knotens, den die App für den
|
|
368
|
-
// Content-Tree registriert (z.B. "publicstatus:nav:content"). Die App besitzt
|
|
369
|
-
// Label/Icon/Access des Knotens (managed-pages-Konvention) — das Feature
|
|
370
|
-
// liefert nur die Kinder + den Editor. Ohne navId: nur der Resolver, kein
|
|
371
|
-
// Sidebar-Knoten (server-only-Consumer wie money-horse leaken nichts).
|
|
372
|
-
// `tenantId` (SystemAdmin-only): welchen Tenant der Content-Tree + Editor
|
|
373
|
-
// bedienen — SYSTEM_TENANT_ID für Apps, die Marketing/Legal dort seeden.
|
|
374
|
-
// Weglassen → Session-Tenant (Default; kein Cross-Tenant-Zugriff).
|
|
375
|
-
export function textContentClient(opts?: {
|
|
376
|
-
readonly navId?: string;
|
|
377
|
-
readonly tenantId?: string;
|
|
378
|
-
}): ClientFeatureDefinition {
|
|
379
|
-
const navId = opts?.navId;
|
|
380
|
-
return {
|
|
381
|
-
name: "text-content",
|
|
382
|
-
...(navId !== undefined && {
|
|
383
|
-
navProviders: { [navId]: makeTreeProvider(opts?.tenantId) },
|
|
384
|
-
// SSE-Refresh: jedes text-block-Event re-fired den Provider → Tree live.
|
|
385
|
-
navEntities: { [navId]: ["text-block"] },
|
|
386
|
-
}),
|
|
387
|
-
resolvers: {
|
|
388
|
-
"text-content:edit": TextContentEditor,
|
|
389
|
-
},
|
|
390
|
-
};
|
|
391
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// @runtime client
|
|
2
|
-
// Public exports für die Browser-Seite des text-content Features.
|
|
3
|
-
// Wird über den Sub-Path-Export `@cosmicdrift/kumiko-bundled-features/text-content/web`
|
|
4
|
-
// konsumiert — die Server-Seite (defineFeature) lebt in
|
|
5
|
-
// `@cosmicdrift/kumiko-bundled-features/text-content` und hat keine
|
|
6
|
-
// React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
|
|
7
|
-
|
|
8
|
-
export { textContentClient } from "./client-plugin";
|