@cosmicdrift/kumiko-bundled-features 0.3.0 → 0.4.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/CHANGELOG.md +49 -0
- package/package.json +7 -5
- package/src/delivery/__tests__/delivery.integration.ts +6 -0
- package/src/delivery/delivery-service.ts +4 -12
- package/src/delivery/feature.ts +6 -4
- package/src/delivery/index.ts +0 -1
- package/src/legal-pages/web/client-plugin.ts +50 -10
- package/src/renderer-foundation/README.md +86 -0
- package/src/renderer-foundation/__tests__/api.test.ts +188 -0
- package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
- package/src/renderer-foundation/api.ts +106 -0
- package/src/renderer-foundation/constants.ts +21 -0
- package/src/renderer-foundation/feature.ts +47 -0
- package/src/renderer-foundation/index.ts +25 -0
- package/src/renderer-foundation/types.ts +109 -0
- package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
- package/src/renderer-simple/feature.ts +28 -3
- package/src/template-resolver/README.md +89 -0
- package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
- package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
- package/src/template-resolver/api.ts +189 -0
- package/src/template-resolver/constants.ts +28 -0
- package/src/template-resolver/feature.ts +36 -0
- package/src/template-resolver/handlers/archive.write.ts +42 -0
- package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
- package/src/template-resolver/handlers/list.query.ts +69 -0
- package/src/template-resolver/handlers/publish.write.ts +45 -0
- package/src/template-resolver/handlers/shared.ts +41 -0
- package/src/template-resolver/handlers/upsert-system.write.ts +75 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +98 -0
- package/src/template-resolver/index.ts +28 -0
- package/src/template-resolver/qualified-names.ts +24 -0
- package/src/template-resolver/table.ts +67 -0
- package/src/text-content/__tests__/text-content.integration.ts +54 -0
- package/src/text-content/handlers/by-slug.query.ts +1 -0
- package/src/text-content/handlers/by-tenant.query.ts +2 -0
- package/src/text-content/handlers/set.write.ts +23 -0
- package/src/text-content/seeding.ts +9 -1
- package/src/text-content/table.ts +6 -0
- package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
- package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
- package/src/text-content/web/client-plugin.tsx +378 -0
- package/src/text-content/web/client-plugin.ts +0 -113
|
@@ -0,0 +1,378 @@
|
|
|
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): void {
|
|
78
|
+
const leaf: TreeNode = {
|
|
79
|
+
label: block.title || block.slug,
|
|
80
|
+
target: {
|
|
81
|
+
featureId: "text-content",
|
|
82
|
+
action: "edit",
|
|
83
|
+
args: { slug: block.slug, lang: block.lang },
|
|
84
|
+
},
|
|
85
|
+
state: block.body ? "filled" : "stub",
|
|
86
|
+
};
|
|
87
|
+
if (block.folder === null) {
|
|
88
|
+
root.leaves.push(leaf);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let cur = root;
|
|
92
|
+
for (const segment of block.folder.split("/")) {
|
|
93
|
+
let next = cur.subFolders.get(segment);
|
|
94
|
+
if (next === undefined) {
|
|
95
|
+
next = newFolderNode();
|
|
96
|
+
cur.subFolders.set(segment, next);
|
|
97
|
+
}
|
|
98
|
+
cur = next;
|
|
99
|
+
}
|
|
100
|
+
cur.leaves.push(leaf);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderFolderNode(node: FolderNode): TreeNode[] {
|
|
104
|
+
// Leaves first (root-items), dann Folders alphabetisch.
|
|
105
|
+
const result: TreeNode[] = [...node.leaves];
|
|
106
|
+
for (const folderName of [...node.subFolders.keys()].sort()) {
|
|
107
|
+
const subNode = node.subFolders.get(folderName);
|
|
108
|
+
if (subNode === undefined) continue;
|
|
109
|
+
result.push({
|
|
110
|
+
label: folderName,
|
|
111
|
+
icon: "folder",
|
|
112
|
+
state: "filled",
|
|
113
|
+
children: renderFolderNode(subNode),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function groupBlocksByFolder(blocks: readonly BlockSummary[]): readonly TreeNode[] {
|
|
120
|
+
const root = newFolderNode();
|
|
121
|
+
for (const block of blocks) {
|
|
122
|
+
attachBlock(root, block);
|
|
123
|
+
}
|
|
124
|
+
const rendered = renderFolderNode(root);
|
|
125
|
+
if (rendered.length === 0) return [];
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
label: "Content",
|
|
129
|
+
icon: "folder",
|
|
130
|
+
state: "filled",
|
|
131
|
+
children: rendered,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const treeProvider: TreeChildrenSubscribe = () => (emit, emitError) => {
|
|
137
|
+
// CSRF-Header bei authenticated requests pflicht (auth-middleware
|
|
138
|
+
// double-submit pattern). Anonymous/Pre-Login wäre csrf-token=undefined
|
|
139
|
+
// → header weggelassen → server lässt die anonymous-Variante durch.
|
|
140
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
141
|
+
const csrf = readCsrfToken();
|
|
142
|
+
if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
|
|
143
|
+
fetch("/api/query", {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers,
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
type: TextContentQueries.byTenant,
|
|
148
|
+
payload: {},
|
|
149
|
+
}),
|
|
150
|
+
})
|
|
151
|
+
.then(async (r) => {
|
|
152
|
+
if (!r.ok) {
|
|
153
|
+
// 403 (CSRF/auth) oder 5xx — explicit error statt silent empty.
|
|
154
|
+
const text = await r.text().catch(() => r.statusText);
|
|
155
|
+
throw new Error(`text-content load failed: ${r.status} ${text}`);
|
|
156
|
+
}
|
|
157
|
+
return r.json();
|
|
158
|
+
})
|
|
159
|
+
.then((data: ByTenantResponse) => {
|
|
160
|
+
const nodes = groupBlocksByFolder(data.data.blocks);
|
|
161
|
+
emit(nodes);
|
|
162
|
+
})
|
|
163
|
+
.catch((e) => {
|
|
164
|
+
// V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
|
|
165
|
+
// Banner + Retry-Button. Fallback auf emit([]) wenn der Consumer
|
|
166
|
+
// kein emitError unterstützt (Tests etc.).
|
|
167
|
+
if (emitError) {
|
|
168
|
+
emitError(e instanceof Error ? e : new Error(String(e)));
|
|
169
|
+
} else {
|
|
170
|
+
emit([]);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
return () => {};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// V.1.3 echte Edit-Form: lädt aktuelle Werte via by-slug-query, lässt
|
|
177
|
+
// TenantAdmin/SystemAdmin title + body editieren, dispatcht set-write
|
|
178
|
+
// bei Submit. Non-Admin-User sehen die Form read-only mit Hint-Banner —
|
|
179
|
+
// das ist der Kumiko-Weg (Memory `[Sicherheit > Convenience]`: write-
|
|
180
|
+
// permission bleibt opinionated TenantAdmin-only, App-Roles erweitern
|
|
181
|
+
// per Dual-Role-Mapping wenn gewollt).
|
|
182
|
+
//
|
|
183
|
+
// **Stale-Tree-Caveat (V.1.4-Followup)**: TreeProvider ist fetch-once.
|
|
184
|
+
// Nach erfolgreichem Save flippt der visual state="stub"→"filled" in
|
|
185
|
+
// der Sidebar NICHT, bis der User den Workspace re-mountet. Editor selbst
|
|
186
|
+
// ist konsistent (lokaler Form-State trägt die neuen Werte). Echte
|
|
187
|
+
// Lösung: SSE-driven Tree-Refresh oder explicit cache-bust nach set-write.
|
|
188
|
+
|
|
189
|
+
type TextBlock = {
|
|
190
|
+
readonly slug: string;
|
|
191
|
+
readonly lang: string;
|
|
192
|
+
readonly title: string;
|
|
193
|
+
readonly body: string | null;
|
|
194
|
+
readonly folder: string | null;
|
|
195
|
+
readonly updatedAt: string;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
type SetResponse = { readonly slug: string; readonly lang: string; readonly isNew: boolean };
|
|
199
|
+
|
|
200
|
+
function TextContentEditor({
|
|
201
|
+
target,
|
|
202
|
+
onClose,
|
|
203
|
+
}: {
|
|
204
|
+
readonly target: TargetRef;
|
|
205
|
+
readonly onClose: () => void;
|
|
206
|
+
}): ReactNode {
|
|
207
|
+
// @cast-boundary visual-tree-args — TargetRef.args ist erased zu
|
|
208
|
+
// Record<string, unknown>; der Resolver kennt das Action-Shape (siehe
|
|
209
|
+
// treeActions.edit-Definition unten) und de-erased pro Action analog
|
|
210
|
+
// zu Event-Payloads. Optional-Chain absorbiert fehlende Felder ohne
|
|
211
|
+
// throw, damit der Editor auch bei manuellem URL-Tampering nicht
|
|
212
|
+
// crasht (TargetRef könnte aus old localStorage / URL-State stammen).
|
|
213
|
+
const args = target.args as { slug?: string; lang?: string } | undefined;
|
|
214
|
+
const slug = args?.slug ?? "";
|
|
215
|
+
const lang = args?.lang ?? "";
|
|
216
|
+
|
|
217
|
+
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
218
|
+
const dispatcher = useDispatcher();
|
|
219
|
+
const user = useShellUser();
|
|
220
|
+
const canWrite =
|
|
221
|
+
user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
|
|
222
|
+
|
|
223
|
+
// Load existing block via by-slug-query. Result ist entweder TextBlock
|
|
224
|
+
// oder null (slug existiert nicht — create-flow). useQuery returnt
|
|
225
|
+
// `data: T | null`, initial-loading: data=null + loading=true.
|
|
226
|
+
const {
|
|
227
|
+
data: loaded,
|
|
228
|
+
loading,
|
|
229
|
+
error: loadError,
|
|
230
|
+
} = useQuery<TextBlock | null>(
|
|
231
|
+
TextContentQueries.bySlug,
|
|
232
|
+
{ slug, lang },
|
|
233
|
+
{ enabled: slug !== "" && lang !== "" },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Form-State unabhängig vom geladenen Block. Sync nur initial oder
|
|
237
|
+
// wenn target.slug+lang wechselt (User springt zwischen Knoten).
|
|
238
|
+
const [title, setTitle] = useState("");
|
|
239
|
+
const [body, setBody] = useState("");
|
|
240
|
+
const [folder, setFolder] = useState("");
|
|
241
|
+
const [submitting, setSubmitting] = useState(false);
|
|
242
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
243
|
+
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
|
244
|
+
|
|
245
|
+
// Sync loaded data → form state. Trigger sobald loaded-shape eindeutig
|
|
246
|
+
// ist (data ≠ undefined). loaded === null heißt "Block existiert noch
|
|
247
|
+
// nicht" — leere Form für create-flow.
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (loading) return;
|
|
250
|
+
setTitle(loaded?.title ?? "");
|
|
251
|
+
setBody(loaded?.body ?? "");
|
|
252
|
+
setFolder(loaded?.folder ?? "");
|
|
253
|
+
setSaveError(null);
|
|
254
|
+
setSavedMsg(null);
|
|
255
|
+
}, [loading, loaded]);
|
|
256
|
+
|
|
257
|
+
const handleSave = async (): Promise<void> => {
|
|
258
|
+
setSubmitting(true);
|
|
259
|
+
setSaveError(null);
|
|
260
|
+
setSavedMsg(null);
|
|
261
|
+
try {
|
|
262
|
+
const result = await dispatcher.write<SetResponse>(TextContentHandlers.set, {
|
|
263
|
+
slug,
|
|
264
|
+
lang,
|
|
265
|
+
title,
|
|
266
|
+
body: body.length > 0 ? body : null,
|
|
267
|
+
folder: folder.length > 0 ? folder : null,
|
|
268
|
+
});
|
|
269
|
+
if (result.isSuccess) {
|
|
270
|
+
setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
|
|
271
|
+
} else {
|
|
272
|
+
setSaveError(result.error.message ?? result.error.code ?? "Speichern fehlgeschlagen.");
|
|
273
|
+
}
|
|
274
|
+
} catch (e) {
|
|
275
|
+
// Network-blip / dispatcher-throw — sonst bleibt submitting=true,
|
|
276
|
+
// Save-Button locked-forever, User klickt repeat ohne Feedback.
|
|
277
|
+
// Generic message reicht: konkreter Recovery-Pfad ist Retry.
|
|
278
|
+
setSaveError(e instanceof Error ? e.message : "Netzwerkfehler beim Speichern.");
|
|
279
|
+
} finally {
|
|
280
|
+
setSubmitting(false);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
285
|
+
e?.preventDefault();
|
|
286
|
+
void handleSave();
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const disabled = submitting || loading || !canWrite;
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<div className="flex h-full flex-col">
|
|
293
|
+
<header className="flex items-center justify-between border-b px-6 py-4">
|
|
294
|
+
<div>
|
|
295
|
+
<h2 className="text-lg font-semibold">Text-Block bearbeiten</h2>
|
|
296
|
+
<p className="text-xs text-muted-foreground">
|
|
297
|
+
{slug || "—"} ({lang || "—"})
|
|
298
|
+
</p>
|
|
299
|
+
</div>
|
|
300
|
+
<Button variant="secondary" onClick={onClose}>
|
|
301
|
+
schließen
|
|
302
|
+
</Button>
|
|
303
|
+
</header>
|
|
304
|
+
<div className="flex-1 overflow-y-auto px-6 py-6">
|
|
305
|
+
<Form onSubmit={onSubmit}>
|
|
306
|
+
{loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
|
|
307
|
+
{loadError !== null && (
|
|
308
|
+
<Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
|
|
309
|
+
)}
|
|
310
|
+
{!canWrite && !loading && (
|
|
311
|
+
<Banner variant="info">
|
|
312
|
+
Read-only — TenantAdmin- oder SystemAdmin-Rolle für Änderungen erforderlich.
|
|
313
|
+
</Banner>
|
|
314
|
+
)}
|
|
315
|
+
<Field id="text-content-title" label="Titel" required>
|
|
316
|
+
<Input
|
|
317
|
+
kind="text"
|
|
318
|
+
id="text-content-title"
|
|
319
|
+
name="text-content-title"
|
|
320
|
+
value={title}
|
|
321
|
+
onChange={setTitle}
|
|
322
|
+
disabled={disabled}
|
|
323
|
+
required
|
|
324
|
+
/>
|
|
325
|
+
</Field>
|
|
326
|
+
<Field id="text-content-folder" label="Ordner (optional)">
|
|
327
|
+
<Input
|
|
328
|
+
kind="text"
|
|
329
|
+
id="text-content-folder"
|
|
330
|
+
name="text-content-folder"
|
|
331
|
+
value={folder}
|
|
332
|
+
onChange={setFolder}
|
|
333
|
+
disabled={disabled}
|
|
334
|
+
placeholder="z.B. page oder legal"
|
|
335
|
+
/>
|
|
336
|
+
</Field>
|
|
337
|
+
<Field id="text-content-body" label="Inhalt">
|
|
338
|
+
<Input
|
|
339
|
+
kind="textarea"
|
|
340
|
+
id="text-content-body"
|
|
341
|
+
name="text-content-body"
|
|
342
|
+
value={body}
|
|
343
|
+
onChange={setBody}
|
|
344
|
+
disabled={disabled}
|
|
345
|
+
rows={14}
|
|
346
|
+
/>
|
|
347
|
+
</Field>
|
|
348
|
+
{saveError !== null && <Banner variant="error">{saveError}</Banner>}
|
|
349
|
+
{savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
|
|
350
|
+
{canWrite && (
|
|
351
|
+
<Button type="submit" loading={submitting} disabled={disabled}>
|
|
352
|
+
{submitting ? "Speichern…" : "Speichern"}
|
|
353
|
+
</Button>
|
|
354
|
+
)}
|
|
355
|
+
</Form>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function textContentClient(): ClientFeatureDefinition {
|
|
362
|
+
return {
|
|
363
|
+
name: "text-content",
|
|
364
|
+
treeProvider,
|
|
365
|
+
// V.1.5b: SSE-driven Tree-Refresh. Bei jedem text-block-Event
|
|
366
|
+
// (created/updated/deleted) ruft ProviderBranch den treeProvider
|
|
367
|
+
// neu auf → Tree-State spiegelt save sofort wider (Stale-Tree-Fix).
|
|
368
|
+
treeEntities: ["text-block"],
|
|
369
|
+
treeActions: {
|
|
370
|
+
edit: { args: { slug: "" as string, lang: "" as string } },
|
|
371
|
+
list: {},
|
|
372
|
+
create: { args: { folder: "" as string } },
|
|
373
|
+
},
|
|
374
|
+
resolvers: {
|
|
375
|
+
"text-content:edit": TextContentEditor,
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
@@ -1,113 +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 Slug-Prefix gruppiert und als TreeNode[] emitted.
|
|
6
|
-
//
|
|
7
|
-
// **Slug-Gruppierung**: Slugs der Form `<prefix>:<rest>` oder `<prefix>/<rest>`
|
|
8
|
-
// werden unter einem `<prefix>`-Container-Knoten gruppiert. Slugs ohne
|
|
9
|
-
// Trenner landen als Top-Level-Knoten. Beispiele:
|
|
10
|
-
// - "page:index:hero.title" → folder "page", label "index:hero.title"
|
|
11
|
-
// - "imprint" → root-node, label "imprint"
|
|
12
|
-
// V.1.3+ kann mehrstufige Hierarchien einführen wenn realer Bedarf zeigt.
|
|
13
|
-
//
|
|
14
|
-
// **State**: TreeNode.state = "filled" wenn body gesetzt ist, sonst
|
|
15
|
-
// "stub" (hellgrau, Designer-Hinweis dass Slug existiert aber leer ist).
|
|
16
|
-
//
|
|
17
|
-
// **Fetch statt Subscribe**: V.1.1 ist Fetch-once beim Mount. Unsubscribe
|
|
18
|
-
// ist no-op. V.1.3+ kann SSE-driven Re-Emit einbauen wenn text-block-
|
|
19
|
-
// updated-Events propagiert werden.
|
|
20
|
-
|
|
21
|
-
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
23
|
-
import { TextContentQueries } from "../constants";
|
|
24
|
-
|
|
25
|
-
type BlockSummary = {
|
|
26
|
-
readonly slug: string;
|
|
27
|
-
readonly lang: string;
|
|
28
|
-
readonly title: string;
|
|
29
|
-
readonly body: string | null;
|
|
30
|
-
readonly updatedAt: string;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
type ByTenantResponse = {
|
|
34
|
-
readonly data: { readonly blocks: readonly BlockSummary[] };
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
// Folder-Name = alles vor dem ersten ":" oder "/", oder undefined wenn
|
|
38
|
-
// der Slug keinen Trenner enthält (dann landet er als Root-Node).
|
|
39
|
-
function getFolderName(slug: string): string | undefined {
|
|
40
|
-
const sepIdx = slug.search(/[:/]/);
|
|
41
|
-
if (sepIdx === -1) return undefined;
|
|
42
|
-
return slug.slice(0, sepIdx);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function groupBlocksBySlugPrefix(blocks: readonly BlockSummary[]): readonly TreeNode[] {
|
|
46
|
-
const rootNodes: TreeNode[] = [];
|
|
47
|
-
const folders = new Map<string, TreeNode[]>();
|
|
48
|
-
|
|
49
|
-
for (const block of blocks) {
|
|
50
|
-
const node: TreeNode = {
|
|
51
|
-
label: block.title || block.slug,
|
|
52
|
-
target: {
|
|
53
|
-
featureId: "text-content",
|
|
54
|
-
action: "edit",
|
|
55
|
-
args: { slug: block.slug, lang: block.lang },
|
|
56
|
-
},
|
|
57
|
-
state: block.body ? "filled" : "stub",
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const folderName = getFolderName(block.slug);
|
|
61
|
-
if (folderName === undefined) {
|
|
62
|
-
rootNodes.push(node);
|
|
63
|
-
} else {
|
|
64
|
-
const existing = folders.get(folderName) ?? [];
|
|
65
|
-
existing.push(node);
|
|
66
|
-
folders.set(folderName, existing);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
for (const [name, children] of folders) {
|
|
71
|
-
rootNodes.push({
|
|
72
|
-
label: name,
|
|
73
|
-
icon: "folder",
|
|
74
|
-
state: "filled",
|
|
75
|
-
children,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return rootNodes;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const treeProvider: TreeChildrenSubscribe = (_ctx) => (emit) => {
|
|
83
|
-
fetch("/api/query", {
|
|
84
|
-
method: "POST",
|
|
85
|
-
headers: { "content-type": "application/json" },
|
|
86
|
-
body: JSON.stringify({
|
|
87
|
-
type: TextContentQueries.byTenant,
|
|
88
|
-
payload: {},
|
|
89
|
-
}),
|
|
90
|
-
})
|
|
91
|
-
.then((r) => r.json())
|
|
92
|
-
.then((data: ByTenantResponse) => {
|
|
93
|
-
const nodes = groupBlocksBySlugPrefix(data.data.blocks);
|
|
94
|
-
emit(nodes);
|
|
95
|
-
})
|
|
96
|
-
.catch(() => {
|
|
97
|
-
// V.1.3+ TODO: state="error"-Knoten + Reload-Action statt empty.
|
|
98
|
-
emit([]);
|
|
99
|
-
});
|
|
100
|
-
return () => {};
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
export function textContentClient(): ClientFeatureDefinition {
|
|
104
|
-
return {
|
|
105
|
-
name: "text-content",
|
|
106
|
-
treeProvider,
|
|
107
|
-
treeActions: {
|
|
108
|
-
edit: { args: { slug: "" as string, lang: "" as string } },
|
|
109
|
-
list: {},
|
|
110
|
-
create: { args: { folder: "" as string } },
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
}
|