includio-cms 0.7.2 → 0.13.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 +110 -0
- package/ROADMAP.md +40 -2
- package/dist/admin/api/generate-styles.d.ts +2 -0
- package/dist/admin/api/generate-styles.js +32 -0
- package/dist/admin/api/handler.js +33 -0
- package/dist/admin/api/media-gc.js +10 -4
- package/dist/admin/api/rest/handler.js +17 -0
- package/dist/admin/api/rest/routes/collections.js +25 -13
- package/dist/admin/api/rest/routes/entries.d.ts +1 -1
- package/dist/admin/api/rest/routes/entries.js +10 -10
- package/dist/admin/api/rest/routes/media.d.ts +2 -0
- package/dist/admin/api/rest/routes/media.js +9 -0
- package/dist/admin/api/rest/routes/schema.d.ts +5 -0
- package/dist/admin/api/rest/routes/schema.js +152 -0
- package/dist/admin/api/rest/routes/singletons.d.ts +1 -1
- package/dist/admin/api/rest/routes/singletons.js +8 -7
- package/dist/admin/api/rest/routes/upload.d.ts +2 -0
- package/dist/admin/api/rest/routes/upload.js +28 -0
- package/dist/admin/api/upload.js +13 -0
- package/dist/admin/client/collection/collection-entries.svelte +19 -6
- package/dist/admin/client/entry/entry.svelte +21 -23
- package/dist/admin/client/entry/header/a11y-validator.js +2 -2
- package/dist/admin/client/entry/header/publish-panel.svelte +33 -85
- package/dist/admin/client/entry/header/status-badge.svelte +2 -2
- package/dist/admin/client/entry/header/version-history-sheet.svelte +9 -9
- package/dist/admin/client/entry/header/visibility.svelte +16 -10
- package/dist/admin/client/entry/utils.d.ts +3 -0
- package/dist/admin/client/entry/utils.js +22 -4
- package/dist/admin/client/form/form-submission/form-submission-page.svelte +4 -1
- package/dist/admin/client/form/form-submission/submission-field.svelte +10 -0
- package/dist/admin/client/index.d.ts +1 -0
- package/dist/admin/client/index.js +1 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte +146 -2
- package/dist/admin/components/fields/blocks-field.svelte +9 -10
- package/dist/admin/components/fields/field-renderer.svelte +4 -8
- package/dist/admin/components/fields/object-field.svelte +7 -12
- package/dist/admin/components/fields/select-field.svelte +8 -2
- package/dist/admin/components/fields/seo-field.svelte +40 -93
- package/dist/admin/components/fields/simple-array-field.svelte +5 -5
- package/dist/admin/components/fields/text-field-wrapper.svelte +52 -197
- package/dist/admin/components/fields/text-field-wrapper.svelte.d.ts +2 -2
- package/dist/admin/components/fields/url-field-wrapper.svelte +15 -25
- package/dist/admin/components/fields/url-field.svelte +61 -72
- package/dist/admin/components/media/file-upload.svelte +5 -1
- package/dist/admin/components/media/file-upload.svelte.d.ts +1 -0
- package/dist/admin/components/media/media-library.svelte +109 -37
- package/dist/admin/components/media/media-selector.svelte +79 -11
- package/dist/admin/components/media/tag-sidebar.svelte +10 -6
- package/dist/admin/components/media/tag-sidebar.svelte.d.ts +7 -2
- package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +21 -93
- package/dist/admin/components/tiptap/inline-block-node.js +6 -5
- package/dist/admin/components/tiptap/link-dialog.svelte +10 -11
- package/dist/admin/components/tiptap/slash-command.js +1 -1
- package/dist/admin/remote/entry.remote.d.ts +2 -5
- package/dist/admin/remote/entry.remote.js +22 -27
- package/dist/admin/remote/media.remote.d.ts +15 -0
- package/dist/admin/remote/media.remote.js +18 -2
- package/dist/admin/remote/preview.remote.js +3 -1
- package/dist/admin/utils/entryLabel.js +9 -6
- package/dist/admin/utils/translationStatus.js +1 -2
- package/dist/cli/scaffold/admin.js +34 -2
- package/dist/cms/runtime/api.d.ts +16 -12
- package/dist/cms/runtime/api.js +7 -6
- package/dist/cms/runtime/remote.js +2 -2
- package/dist/cms/runtime/schemas.d.ts +1 -1
- package/dist/cms/runtime/schemas.js +1 -1
- package/dist/cms/runtime/types.d.ts +118 -112
- package/dist/cms/runtime/types.js +0 -12
- package/dist/core/cms.d.ts +3 -1
- package/dist/core/cms.js +30 -0
- package/dist/core/fields/fieldSchemaToTs.js +9 -15
- package/dist/core/fields/formFieldSchemaToTs.js +7 -0
- package/dist/core/server/entries/operations/create.js +10 -4
- package/dist/core/server/entries/operations/get.d.ts +1 -0
- package/dist/core/server/entries/operations/get.js +186 -191
- package/dist/core/server/entries/operations/update.d.ts +6 -7
- package/dist/core/server/entries/operations/update.js +20 -38
- package/dist/core/server/fields/populateEntry.js +16 -52
- package/dist/core/server/fields/resolveImageFields.js +69 -120
- package/dist/core/server/fields/resolveRelationFields.js +30 -51
- package/dist/core/server/fields/resolveRichtextLinks.js +46 -100
- package/dist/core/server/fields/resolveTypographyOrphans.bench.d.ts +1 -0
- package/dist/core/server/fields/resolveTypographyOrphans.bench.js +87 -0
- package/dist/core/server/fields/resolveTypographyOrphans.d.ts +3 -0
- package/dist/core/server/fields/resolveTypographyOrphans.js +128 -0
- package/dist/core/server/fields/resolveUrlFields.js +47 -56
- package/dist/core/server/fields/utils/fixOrphans.d.ts +5 -0
- package/dist/core/server/fields/utils/fixOrphans.js +12 -0
- package/dist/core/server/fields/utils/imageStyles.d.ts +4 -2
- package/dist/core/server/fields/utils/imageStyles.js +41 -25
- package/dist/core/server/fields/utils/resolveMedia.js +1 -6
- package/dist/core/server/forms/submissions/operations/delete.js +26 -2
- package/dist/core/server/forms/submissions/utils/parseMultipart.d.ts +2 -0
- package/dist/core/server/forms/submissions/utils/parseMultipart.js +75 -0
- package/dist/core/server/generator/fields.d.ts +6 -0
- package/dist/core/server/generator/fields.js +43 -5
- package/dist/core/server/generator/formFieldSchemaToString.js +10 -0
- package/dist/core/server/generator/formFields.js +1 -0
- package/dist/core/server/generator/generator.js +98 -30
- package/dist/core/server/media/operations/getFiles.d.ts +5 -0
- package/dist/core/server/media/operations/getFiles.js +6 -0
- package/dist/core/server/media/operations/uploadPrivateFile.d.ts +4 -0
- package/dist/core/server/media/operations/uploadPrivateFile.js +8 -0
- package/dist/core/server/media/styles/operations/batchGenerateStyles.d.ts +16 -0
- package/dist/core/server/media/styles/operations/batchGenerateStyles.js +144 -0
- package/dist/db-postgres/index.js +303 -37
- package/dist/db-postgres/schema/entry.d.ts +0 -94
- package/dist/db-postgres/schema/entry.js +0 -6
- package/dist/db-postgres/schema/entryVersion.d.ts +17 -0
- package/dist/db-postgres/schema/entryVersion.js +1 -0
- package/dist/entity/index.d.ts +9 -4
- package/dist/entity/index.js +24 -24
- package/dist/files-local/index.js +43 -0
- package/dist/paraglide/messages/_index.d.ts +36 -3
- package/dist/paraglide/messages/_index.js +71 -3
- package/dist/paraglide/messages/en.d.ts +5 -0
- package/dist/paraglide/messages/en.js +14 -0
- package/dist/paraglide/messages/pl.d.ts +5 -0
- package/dist/paraglide/messages/pl.js +14 -0
- package/dist/sveltekit/components/preview.svelte +2 -326
- package/dist/sveltekit/components/preview.svelte.d.ts +5 -16
- package/dist/sveltekit/server/index.d.ts +2 -1
- package/dist/sveltekit/server/index.js +2 -1
- package/dist/sveltekit/server/preview.js +4 -7
- package/dist/types/adapters/db.d.ts +15 -1
- package/dist/types/adapters/files.d.ts +6 -0
- package/dist/types/cms.d.ts +5 -0
- package/dist/types/entries.d.ts +54 -18
- package/dist/types/fields.d.ts +14 -24
- package/dist/types/formFields.d.ts +7 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/structured-content.d.ts +5 -0
- package/dist/updates/0.10.0/index.d.ts +2 -0
- package/dist/updates/0.10.0/index.js +15 -0
- package/dist/updates/0.11.0/index.d.ts +2 -0
- package/dist/updates/0.11.0/index.js +12 -0
- package/dist/updates/0.12.0/index.d.ts +2 -0
- package/dist/updates/0.12.0/index.js +12 -0
- package/dist/updates/0.13.0/index.d.ts +2 -0
- package/dist/updates/0.13.0/index.js +10 -0
- package/dist/updates/0.7.3/index.d.ts +2 -0
- package/dist/updates/0.7.3/index.js +10 -0
- package/dist/updates/0.8.0/index.d.ts +2 -0
- package/dist/updates/0.8.0/index.js +18 -0
- package/dist/updates/0.8.0/migrate.d.ts +2 -0
- package/dist/updates/0.8.0/migrate.js +101 -0
- package/dist/updates/0.9.0/index.d.ts +2 -0
- package/dist/updates/0.9.0/index.js +38 -0
- package/dist/updates/index.js +8 -1
- package/package.json +7 -6
- package/dist/admin/components/fields/image-field.svelte +0 -198
- package/dist/admin/components/fields/image-field.svelte.d.ts +0 -8
- package/dist/admin/components/fields/richtext-field.svelte +0 -13
- package/dist/admin/components/fields/richtext-field.svelte.d.ts +0 -8
- package/dist/admin/components/tiptap.svelte +0 -11
- package/dist/admin/components/tiptap.svelte.d.ts +0 -6
- package/dist/core/server/entries/utils/getEntryTranslation.d.ts +0 -1
- package/dist/core/server/entries/utils/getEntryTranslation.js +0 -18
- package/dist/paraglide/messages/hello_world.d.ts +0 -5
- package/dist/paraglide/messages/hello_world.js +0 -33
- package/dist/paraglide/messages/login_hello.d.ts +0 -16
- package/dist/paraglide/messages/login_hello.js +0 -34
- package/dist/paraglide/messages/login_please_login.d.ts +0 -16
- package/dist/paraglide/messages/login_please_login.js +0 -34
|
@@ -26,114 +26,44 @@
|
|
|
26
26
|
|
|
27
27
|
const SKIP_TYPES: Set<FieldType> = new Set(['slug', 'seo']);
|
|
28
28
|
|
|
29
|
+
/** Ensure blockData has default values for all fields (no per-lang wrapping — data is flat) */
|
|
29
30
|
function normalizeBlockData(
|
|
30
31
|
data: Record<string, unknown>,
|
|
31
|
-
fields: Field[]
|
|
32
|
-
langs: string[]
|
|
32
|
+
fields: Field[]
|
|
33
33
|
): Record<string, unknown> {
|
|
34
34
|
const result = { ...data };
|
|
35
35
|
for (const f of fields) {
|
|
36
36
|
const key = f.slug;
|
|
37
|
-
if (['text', 'richtext'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
typeof val !== 'object' ||
|
|
42
|
-
(f.type === 'content' && 'type' in (val as Record<string, unknown>));
|
|
43
|
-
if (needsWrap) {
|
|
44
|
-
const fallback = val ?? (f.type === 'content' ? null : '');
|
|
45
|
-
result[key] = Object.fromEntries(langs.map((l) => [l, fallback]));
|
|
46
|
-
}
|
|
37
|
+
if (['text', 'richtext'].includes(f.type)) {
|
|
38
|
+
if (result[key] == null) result[key] = '';
|
|
39
|
+
} else if (f.type === 'content') {
|
|
40
|
+
// Leave as-is (null or doc)
|
|
47
41
|
} else if (f.type === 'url') {
|
|
48
42
|
const v = result[key] as Record<string, unknown> | undefined;
|
|
49
43
|
if (!v || typeof v !== 'object') {
|
|
50
|
-
result[key] = { id: '', url:
|
|
51
|
-
} else
|
|
52
|
-
|
|
53
|
-
} else if (typeof v.url === 'string') {
|
|
54
|
-
const urlStr = v.url as string;
|
|
55
|
-
const textStr = typeof v.text === 'string' ? v.text : '';
|
|
56
|
-
result[key] = {
|
|
57
|
-
...v,
|
|
58
|
-
url: Object.fromEntries(langs.map((l) => [l, urlStr])),
|
|
59
|
-
text: Object.fromEntries(langs.map((l) => [l, textStr]))
|
|
60
|
-
};
|
|
44
|
+
result[key] = { id: '', url: '' };
|
|
45
|
+
} else {
|
|
46
|
+
if (typeof v.url !== 'string') v.url = '';
|
|
61
47
|
}
|
|
62
48
|
const urlField = f as import('../../../types/fields.js').UrlField;
|
|
63
49
|
const d = result[key] as Record<string, unknown>;
|
|
64
|
-
if (urlField.text &&
|
|
50
|
+
if (urlField.text && d.text == null) d.text = '';
|
|
65
51
|
if (urlField.newTab && d.newTab === undefined) d.newTab = false;
|
|
66
52
|
if (urlField.rel && d.rel === undefined) d.rel = '';
|
|
67
53
|
} else if (f.type === 'blocks') {
|
|
68
54
|
const bf = f as BlocksField;
|
|
69
55
|
if (Array.isArray(result[key])) {
|
|
70
|
-
result[key] = (result[key] as any[]).map((item) =>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
item.
|
|
74
|
-
bf.of.find((d: ObjectField) => d.slug === item.slug)?.fields ?? [],
|
|
75
|
-
langs
|
|
76
|
-
)
|
|
77
|
-
}));
|
|
78
|
-
}
|
|
79
|
-
} else if (f.type === 'object') {
|
|
80
|
-
const of_ = f as ObjectField;
|
|
81
|
-
if (result[key] && typeof result[key] === 'object' && 'data' in (result[key] as object)) {
|
|
82
|
-
const obj = result[key] as Record<string, unknown>;
|
|
83
|
-
result[key] = { ...obj, data: normalizeBlockData(obj.data as Record<string, unknown>, of_.fields, langs) };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return result;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function denormalizeBlockData(
|
|
91
|
-
data: Record<string, unknown>,
|
|
92
|
-
fields: Field[],
|
|
93
|
-
currentLang: string
|
|
94
|
-
): Record<string, unknown> {
|
|
95
|
-
const result = { ...data };
|
|
96
|
-
for (const f of fields) {
|
|
97
|
-
const key = f.slug;
|
|
98
|
-
const val = result[key];
|
|
99
|
-
if (['text', 'richtext', 'content'].includes(f.type)) {
|
|
100
|
-
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
101
|
-
result[key] = (val as Record<string, unknown>)[currentLang] ?? (f.type === 'content' ? null : '');
|
|
102
|
-
}
|
|
103
|
-
} else if (f.type === 'url' && val && typeof val === 'object') {
|
|
104
|
-
const v = val as Record<string, unknown>;
|
|
105
|
-
const flat: Record<string, unknown> = {};
|
|
106
|
-
if (v.id !== undefined) flat.id = v.id;
|
|
107
|
-
flat.url =
|
|
108
|
-
v.url && typeof v.url === 'object'
|
|
109
|
-
? (v.url as Record<string, string>)[currentLang] ?? ''
|
|
110
|
-
: v.url ?? '';
|
|
111
|
-
if ('text' in v) {
|
|
112
|
-
flat.text =
|
|
113
|
-
v.text && typeof v.text === 'object'
|
|
114
|
-
? (v.text as Record<string, string>)[currentLang] ?? ''
|
|
115
|
-
: v.text ?? '';
|
|
116
|
-
}
|
|
117
|
-
if (v.newTab !== undefined) flat.newTab = v.newTab;
|
|
118
|
-
if (v.rel !== undefined) flat.rel = v.rel;
|
|
119
|
-
result[key] = flat;
|
|
120
|
-
} else if (f.type === 'blocks') {
|
|
121
|
-
const bf = f as BlocksField;
|
|
122
|
-
if (Array.isArray(val)) {
|
|
123
|
-
result[key] = (val as any[]).map((item) => ({
|
|
124
|
-
...item,
|
|
125
|
-
data: denormalizeBlockData(
|
|
126
|
-
item.data ?? {},
|
|
127
|
-
bf.of.find((d: ObjectField) => d.slug === item.slug)?.fields ?? [],
|
|
128
|
-
currentLang
|
|
56
|
+
result[key] = (result[key] as any[]).map((item) =>
|
|
57
|
+
normalizeBlockData(
|
|
58
|
+
item,
|
|
59
|
+
bf.of.find((d: ObjectField) => d.slug === item._slug)?.fields ?? []
|
|
129
60
|
)
|
|
130
|
-
|
|
61
|
+
);
|
|
131
62
|
}
|
|
132
63
|
} else if (f.type === 'object') {
|
|
133
64
|
const of_ = f as ObjectField;
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
result[key] = { ...obj, data: denormalizeBlockData(obj.data as Record<string, unknown>, of_.fields, currentLang) };
|
|
65
|
+
if (result[key] && typeof result[key] === 'object') {
|
|
66
|
+
result[key] = normalizeBlockData(result[key] as Record<string, unknown>, of_.fields);
|
|
137
67
|
}
|
|
138
68
|
}
|
|
139
69
|
}
|
|
@@ -145,7 +75,7 @@
|
|
|
145
75
|
);
|
|
146
76
|
|
|
147
77
|
const blockDef = $derived(inlineBlocks.find((b) => b.slug === node.attrs.blockType));
|
|
148
|
-
const blockLabel = $derived(blockDef?.label ? (Object.values(blockDef.label)[0] ?? blockDef.slug) : node.attrs.blockType);
|
|
78
|
+
const blockLabel = $derived(blockDef?.label ? (typeof blockDef.label === 'string' ? blockDef.label : Object.values(blockDef.label)[0] ?? blockDef.slug) : node.attrs.blockType);
|
|
149
79
|
const supportedFields = $derived(blockDef?.fields.filter((f) => !SKIP_TYPES.has(f.type)) ?? []);
|
|
150
80
|
|
|
151
81
|
function parseBlockData(raw: unknown): Record<string, unknown> {
|
|
@@ -156,9 +86,8 @@
|
|
|
156
86
|
return {};
|
|
157
87
|
}
|
|
158
88
|
|
|
159
|
-
const langs = contentLanguage.all;
|
|
160
89
|
const allFields = blockDef?.fields ?? [];
|
|
161
|
-
const standaloneForm = createStandaloneForm(normalizeBlockData(parseBlockData(node.attrs.blockData), allFields
|
|
90
|
+
const standaloneForm = createStandaloneForm(normalizeBlockData(parseBlockData(node.attrs.blockData), allFields));
|
|
162
91
|
const formStore = standaloneForm.form;
|
|
163
92
|
|
|
164
93
|
// Track last JSON we wrote to PM to avoid re-parsing our own writes
|
|
@@ -169,8 +98,7 @@
|
|
|
169
98
|
const unsubscribe = formStore.subscribe((data) => {
|
|
170
99
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
171
100
|
debounceTimer = setTimeout(() => {
|
|
172
|
-
const
|
|
173
|
-
const json = JSON.stringify(denormalized);
|
|
101
|
+
const json = JSON.stringify(data);
|
|
174
102
|
if (json !== lastWrittenJson) {
|
|
175
103
|
lastWrittenJson = json;
|
|
176
104
|
updateAttributes({ blockData: json });
|
|
@@ -188,7 +116,7 @@
|
|
|
188
116
|
const raw = node.attrs.blockData;
|
|
189
117
|
const rawJson = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
190
118
|
if (rawJson !== lastWrittenJson) {
|
|
191
|
-
formStore.set(normalizeBlockData(parseBlockData(raw), allFields
|
|
119
|
+
formStore.set(normalizeBlockData(parseBlockData(raw), allFields));
|
|
192
120
|
}
|
|
193
121
|
});
|
|
194
122
|
|
|
@@ -25,7 +25,6 @@ function getFieldDefault(field) {
|
|
|
25
25
|
return field.defaultValue;
|
|
26
26
|
switch (field.type) {
|
|
27
27
|
case 'text':
|
|
28
|
-
case 'richtext':
|
|
29
28
|
case 'date':
|
|
30
29
|
case 'datetime':
|
|
31
30
|
return '';
|
|
@@ -40,7 +39,6 @@ function getFieldDefault(field) {
|
|
|
40
39
|
return [];
|
|
41
40
|
case 'url':
|
|
42
41
|
return { url: '' };
|
|
43
|
-
case 'image':
|
|
44
42
|
case 'file':
|
|
45
43
|
case 'media':
|
|
46
44
|
case 'relation':
|
|
@@ -119,8 +117,11 @@ export const InlineBlockNode = Node.create({
|
|
|
119
117
|
};
|
|
120
118
|
},
|
|
121
119
|
addNodeView() {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
120
|
+
const Component = InlineBlockNodeView;
|
|
121
|
+
const baseContext = this.options.context;
|
|
122
|
+
return (props) => {
|
|
123
|
+
const context = baseContext ? new Map(baseContext) : undefined;
|
|
124
|
+
return SvelteNodeViewRenderer(Component, { context })(props);
|
|
125
|
+
};
|
|
125
126
|
}
|
|
126
127
|
});
|
|
@@ -22,11 +22,10 @@
|
|
|
22
22
|
onOpenChange?: (open: boolean) => void;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
type EntryWithSeo =
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
};
|
|
25
|
+
type EntryWithSeo = {
|
|
26
|
+
_id: string;
|
|
27
|
+
_slug: string;
|
|
28
|
+
seo?: SeoFieldData;
|
|
30
29
|
};
|
|
31
30
|
|
|
32
31
|
let { open = $bindable(false), editor, onOpenChange }: Props = $props();
|
|
@@ -102,16 +101,16 @@
|
|
|
102
101
|
]) as [EntryWithSeo[], EntryWithSeo[]];
|
|
103
102
|
const combined = [...slugResults, ...titleResults];
|
|
104
103
|
const deduped = combined.filter(
|
|
105
|
-
(entry, index, self) => index === self.findIndex((e) => e.
|
|
104
|
+
(entry, index, self) => index === self.findIndex((e) => e._id === entry._id)
|
|
106
105
|
);
|
|
107
106
|
suggestions = deduped.slice(0, 5);
|
|
108
107
|
}, 300);
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
function selectEntry(entry: EntryWithSeo) {
|
|
112
|
-
entryId = entry.
|
|
111
|
+
entryId = entry._id;
|
|
113
112
|
linkedEntry = entry;
|
|
114
|
-
url = entry.
|
|
113
|
+
url = entry.seo?.slug || url;
|
|
115
114
|
suggestions = [];
|
|
116
115
|
}
|
|
117
116
|
|
|
@@ -184,8 +183,8 @@
|
|
|
184
183
|
onclick={unlinkEntry}
|
|
185
184
|
>
|
|
186
185
|
<LinkIcon class="text-muted-foreground h-4 w-4 shrink-0" />
|
|
187
|
-
<span class="truncate font-medium">{linkedEntry.
|
|
188
|
-
<span class="text-muted-foreground truncate text-xs">{linkedEntry.
|
|
186
|
+
<span class="truncate font-medium">{linkedEntry.seo?.title || linkedEntry._id}</span>
|
|
187
|
+
<span class="text-muted-foreground truncate text-xs">{linkedEntry.seo?.slug}</span>
|
|
189
188
|
<X class="ml-auto h-3.5 w-3.5 shrink-0 opacity-50" />
|
|
190
189
|
</button>
|
|
191
190
|
{/if}
|
|
@@ -210,7 +209,7 @@
|
|
|
210
209
|
>
|
|
211
210
|
<Item.Content>
|
|
212
211
|
<Item.Title>
|
|
213
|
-
{suggestion.
|
|
212
|
+
{suggestion.seo?.title || 'Bez tytułu'} ({suggestion.seo?.slug})
|
|
214
213
|
</Item.Title>
|
|
215
214
|
</Item.Content>
|
|
216
215
|
</Item.Root>
|
|
@@ -66,7 +66,7 @@ function getStandardItems(editor) {
|
|
|
66
66
|
function getInlineBlockItems(inlineBlocks, editor) {
|
|
67
67
|
return inlineBlocks.map((block) => ({
|
|
68
68
|
id: `block-${block.slug}`,
|
|
69
|
-
label: block.label ? Object.values(block.label)[0] : block.slug,
|
|
69
|
+
label: block.label ? (typeof block.label === 'string' ? block.label : Object.values(block.label)[0]) : block.slug,
|
|
70
70
|
group: 'Bloki',
|
|
71
71
|
action: () => {
|
|
72
72
|
editor.commands.insertInlineBlock({ blockType: block.slug });
|
|
@@ -56,18 +56,15 @@ export declare const getRawEntry: import("@sveltejs/kit").RemoteQueryFunction<{
|
|
|
56
56
|
export declare const getEntryForEntryPage: import("@sveltejs/kit").RemoteQueryFunction<string, RawEntry>;
|
|
57
57
|
export declare const updateEntryVersionCommand: import("@sveltejs/kit").RemoteCommand<{
|
|
58
58
|
entryId: string;
|
|
59
|
+
lang: string;
|
|
59
60
|
data: Record<string, unknown>;
|
|
60
61
|
type: "draft" | "published-now" | "published-scheduled" | "cancel-published";
|
|
61
62
|
scheduledAt?: Date | undefined;
|
|
62
|
-
}, Promise<
|
|
63
|
+
}, Promise<import("../../types/entries.js").DbEntryVersion | undefined>>;
|
|
63
64
|
export declare const updateEntryCommand: import("@sveltejs/kit").RemoteCommand<{
|
|
64
65
|
id: string;
|
|
65
66
|
data: {
|
|
66
|
-
availableLocales?: string[] | undefined;
|
|
67
67
|
archivedAt?: Date | null | undefined;
|
|
68
|
-
publishedAt?: Date | null | undefined;
|
|
69
|
-
publishedVersionId?: string | null | undefined;
|
|
70
|
-
publishedBy?: string | null | undefined;
|
|
71
68
|
sortOrder?: number | null | undefined;
|
|
72
69
|
};
|
|
73
70
|
}, Promise<import("../../types/entries.js").DbEntry>>;
|
|
@@ -2,7 +2,7 @@ import { command, query } from '$app/server';
|
|
|
2
2
|
import { createEntry as createEntryOperation, createEntrySchema, createEntryVersion } from '../../core/server/entries/operations/create.js';
|
|
3
3
|
import { getRawEntries as getRawEntriesOperation, countRawEntries as countRawEntriesOperation, getRawEntry as getRawEntryOperation, getRawEntryOrThrow, getDbEntry, getDbEntryOrThrow, getEntries as getEntriesOperation, getEntry as getEntryOperation, getEntryVersion as getEntryVersionOperation, getEntryLabels as getEntryLabelsOperation } from '../../core/server/entries/operations/get.js';
|
|
4
4
|
import { getCMS } from '../../core/cms.js';
|
|
5
|
-
import { pruneOldDraftVersions,
|
|
5
|
+
import { pruneOldDraftVersions, unpublishEntryLang, upsertDraftVersion, updateEntry, updateEntrySchema, updateEntryVersionCommandTypes } from '../../core/server/entries/operations/update.js';
|
|
6
6
|
import z from 'zod';
|
|
7
7
|
import { requireAuth } from './middleware/auth.js';
|
|
8
8
|
import { entryStatuses } from '../../types/entries.js';
|
|
@@ -101,6 +101,7 @@ export const getEntryForEntryPage = query(z.string(), async (id) => {
|
|
|
101
101
|
});
|
|
102
102
|
const updateEntryVersionCommandSchema = z.object({
|
|
103
103
|
entryId: z.string().uuid(),
|
|
104
|
+
lang: z.string(),
|
|
104
105
|
data: z.record(z.string(), z.unknown()),
|
|
105
106
|
type: z.enum(updateEntryVersionCommandTypes),
|
|
106
107
|
scheduledAt: z.date().optional()
|
|
@@ -110,49 +111,39 @@ export const updateEntryVersionCommand = command(updateEntryVersionCommandSchema
|
|
|
110
111
|
let result;
|
|
111
112
|
switch (input.type) {
|
|
112
113
|
case 'draft':
|
|
113
|
-
result = await upsertDraftVersion(input.entryId, input.data, { skipValidation: true });
|
|
114
|
+
result = await upsertDraftVersion(input.entryId, input.data, input.lang, { skipValidation: true });
|
|
114
115
|
break;
|
|
115
116
|
case 'published-now': {
|
|
116
|
-
|
|
117
|
-
// Dual-write: still set version.publishedAt for backward compat
|
|
117
|
+
// Create a new published version for this language
|
|
118
118
|
result = await createEntryVersion({
|
|
119
|
-
|
|
119
|
+
entryId: input.entryId,
|
|
120
|
+
lang: input.lang,
|
|
121
|
+
data: input.data,
|
|
120
122
|
publishedAt: new Date(),
|
|
121
123
|
publishedBy: user.id
|
|
122
124
|
});
|
|
123
|
-
// Update entry-level publish state
|
|
124
|
-
await updateEntry(input.entryId, {
|
|
125
|
-
publishedVersionId: result.id,
|
|
126
|
-
publishedBy: user.id,
|
|
127
|
-
publishedAt: existingEntry.publishedAt ?? new Date()
|
|
128
|
-
});
|
|
129
125
|
break;
|
|
130
126
|
}
|
|
131
127
|
case 'published-scheduled': {
|
|
132
128
|
if (!input.scheduledAt) {
|
|
133
129
|
throw new Error('scheduledAt is required for scheduled publishing');
|
|
134
130
|
}
|
|
135
|
-
// Dual-write: still set version.publishedAt for backward compat
|
|
136
131
|
result = await createEntryVersion({
|
|
137
|
-
|
|
132
|
+
entryId: input.entryId,
|
|
133
|
+
lang: input.lang,
|
|
134
|
+
data: input.data,
|
|
138
135
|
publishedAt: input.scheduledAt,
|
|
139
136
|
publishedBy: user.id
|
|
140
137
|
});
|
|
141
|
-
// Update entry-level publish state
|
|
142
|
-
await updateEntry(input.entryId, {
|
|
143
|
-
publishedVersionId: result.id,
|
|
144
|
-
publishedBy: user.id,
|
|
145
|
-
publishedAt: input.scheduledAt
|
|
146
|
-
});
|
|
147
138
|
break;
|
|
148
139
|
}
|
|
149
140
|
case 'cancel-published':
|
|
150
|
-
|
|
141
|
+
await unpublishEntryLang(input.entryId, input.lang);
|
|
151
142
|
break;
|
|
152
143
|
}
|
|
153
144
|
// Prune old draft versions only after publish/schedule (drafts upsert in-place)
|
|
154
145
|
if (input.type !== 'draft') {
|
|
155
|
-
await pruneOldDraftVersions(input.entryId);
|
|
146
|
+
await pruneOldDraftVersions(input.entryId, input.lang);
|
|
156
147
|
// Refresh cached entry so UI reactively updates (status badge, version history)
|
|
157
148
|
await getEntryForEntryPage(input.entryId).refresh();
|
|
158
149
|
}
|
|
@@ -216,8 +207,9 @@ export const getRecentEntries = query(z.number().default(6), async (limit) => {
|
|
|
216
207
|
let label = null;
|
|
217
208
|
if (config && config.type === 'collection' && config.entryAdminTitle && latestVersion) {
|
|
218
209
|
const titleData = latestVersion.data[config.entryAdminTitle];
|
|
219
|
-
|
|
220
|
-
|
|
210
|
+
// Data is flat — titleData is the string directly
|
|
211
|
+
if (typeof titleData === 'string') {
|
|
212
|
+
label = titleData || '';
|
|
221
213
|
}
|
|
222
214
|
}
|
|
223
215
|
if (config && config.type === 'single' && config.label) {
|
|
@@ -237,9 +229,11 @@ export const getRecentEntries = query(z.number().default(6), async (limit) => {
|
|
|
237
229
|
collectionLabel = config.label;
|
|
238
230
|
}
|
|
239
231
|
}
|
|
240
|
-
const
|
|
232
|
+
const hasPublished = Object.values(entry.publishedVersions).some((v) => v != null);
|
|
233
|
+
const hasScheduled = Object.values(entry.scheduledVersions).some((v) => v != null);
|
|
234
|
+
const status = hasPublished
|
|
241
235
|
? 'published'
|
|
242
|
-
:
|
|
236
|
+
: hasScheduled
|
|
243
237
|
? 'scheduled'
|
|
244
238
|
: 'draft';
|
|
245
239
|
return {
|
|
@@ -262,8 +256,9 @@ export const getRecentActivity = query(z.number().default(10), async (limit) =>
|
|
|
262
256
|
let label = null;
|
|
263
257
|
if (config && config.type === 'collection' && config.entryAdminTitle) {
|
|
264
258
|
const titleData = latestVersion.data[config.entryAdminTitle];
|
|
265
|
-
|
|
266
|
-
|
|
259
|
+
// Data is flat — titleData is the string directly
|
|
260
|
+
if (typeof titleData === 'string') {
|
|
261
|
+
label = titleData || null;
|
|
267
262
|
}
|
|
268
263
|
}
|
|
269
264
|
if (config && config.type === 'single' && config.label) {
|
|
@@ -14,8 +14,23 @@ export declare const getMediaFiles: import("@sveltejs/kit").RemoteQueryFunction<
|
|
|
14
14
|
tagIds?: string[] | undefined;
|
|
15
15
|
mimeTypes?: string[] | undefined;
|
|
16
16
|
search?: string | undefined;
|
|
17
|
+
untagged?: boolean | undefined;
|
|
18
|
+
limit?: number | undefined;
|
|
19
|
+
offset?: number | undefined;
|
|
17
20
|
};
|
|
18
21
|
}, import("../../types/media.js").MediaFile[]>;
|
|
22
|
+
export declare const countMediaFiles: import("@sveltejs/kit").RemoteQueryFunction<{
|
|
23
|
+
data: {
|
|
24
|
+
tagIds?: string[] | undefined;
|
|
25
|
+
mimeTypes?: string[] | undefined;
|
|
26
|
+
search?: string | undefined;
|
|
27
|
+
untagged?: boolean | undefined;
|
|
28
|
+
};
|
|
29
|
+
}, number>;
|
|
30
|
+
export declare const getMediaTagsWithCounts: import("@sveltejs/kit").RemoteQueryFunction<void, {
|
|
31
|
+
tag: import("../../types/media.js").MediaTag;
|
|
32
|
+
count: number;
|
|
33
|
+
}[]>;
|
|
19
34
|
export declare const getFileById: import("@sveltejs/kit").RemoteQueryFunction<string, import("../../types/media.js").MediaFile | null>;
|
|
20
35
|
export declare const deleteMediaFile: import("@sveltejs/kit").RemoteCommand<string, Promise<void>>;
|
|
21
36
|
export declare const bulkDeleteMediaFiles: import("@sveltejs/kit").RemoteCommand<{
|
|
@@ -2,7 +2,7 @@ import { command, query } from '$app/server';
|
|
|
2
2
|
import { setAlt, renameMediaFile as renameMediaFileOperation, updateMediaAccessibility as updateMediaAccessibilityOp } from '../../core/server/media/operations/updateFile.js';
|
|
3
3
|
import z from 'zod';
|
|
4
4
|
import { deleteMediaFile as deleteMediaFileFn, bulkDeleteMediaFiles as bulkDeleteMediaFilesFn } from '../../core/server/media/operations/deleteMediaFile.js';
|
|
5
|
-
import { getFile, getFiles } from '../../core/server/media/operations/getFiles.js';
|
|
5
|
+
import { getFile, getFiles, countFiles, getMediaTagsWithCounts as getMediaTagsWithCountsFn } from '../../core/server/media/operations/getFiles.js';
|
|
6
6
|
import { getMediaTags as getMediaTagsFn, createMediaTag as createMediaTagFn, updateMediaTag as updateMediaTagFn, deleteMediaTag as deleteMediaTagFn, setMediaFileTags as setMediaFileTagsFn, bulkSetMediaFileTags as bulkSetMediaFileTagsFn } from '../../core/server/media/operations/tags.js';
|
|
7
7
|
import { requireAuth } from './middleware/auth.js';
|
|
8
8
|
const setMediaFileAltSchema = z.object({
|
|
@@ -34,11 +34,27 @@ export const getMediaFiles = query(z.object({
|
|
|
34
34
|
ids: z.array(z.string().uuid()).optional(),
|
|
35
35
|
tagIds: z.array(z.string().uuid()).optional(),
|
|
36
36
|
mimeTypes: z.array(z.string()).optional(),
|
|
37
|
-
search: z.string().optional()
|
|
37
|
+
search: z.string().optional(),
|
|
38
|
+
untagged: z.boolean().optional(),
|
|
39
|
+
limit: z.number().int().positive().optional(),
|
|
40
|
+
offset: z.number().int().nonnegative().optional()
|
|
38
41
|
})
|
|
39
42
|
}), async (data) => {
|
|
40
43
|
return getFiles(data);
|
|
41
44
|
});
|
|
45
|
+
export const countMediaFiles = query(z.object({
|
|
46
|
+
data: z.object({
|
|
47
|
+
tagIds: z.array(z.string().uuid()).optional(),
|
|
48
|
+
mimeTypes: z.array(z.string()).optional(),
|
|
49
|
+
search: z.string().optional(),
|
|
50
|
+
untagged: z.boolean().optional()
|
|
51
|
+
})
|
|
52
|
+
}), async (data) => {
|
|
53
|
+
return countFiles(data);
|
|
54
|
+
});
|
|
55
|
+
export const getMediaTagsWithCounts = query(async () => {
|
|
56
|
+
return getMediaTagsWithCountsFn();
|
|
57
|
+
});
|
|
42
58
|
export const getFileById = query(z.string().uuid(), async (id) => {
|
|
43
59
|
return getFile(id);
|
|
44
60
|
});
|
|
@@ -10,5 +10,7 @@ const schema = z.object({
|
|
|
10
10
|
});
|
|
11
11
|
export const populatePreviewData = command(schema, async ({ data, slug, language }) => {
|
|
12
12
|
const config = getCMS().getBySlug(slug);
|
|
13
|
-
|
|
13
|
+
const fields = getFieldsFromConfig(config);
|
|
14
|
+
const populated = await populateEntryData(data, fields, language);
|
|
15
|
+
return populated;
|
|
14
16
|
});
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { getAtPath } from './objectPath.js';
|
|
2
2
|
export function getRawCollectionEntryLabel(entry, config, language) {
|
|
3
|
-
|
|
3
|
+
const publishedVersion = entry.publishedVersions[language];
|
|
4
|
+
if (publishedVersion) {
|
|
5
|
+
// Data is flat — entryAdminTitle value is directly a string
|
|
4
6
|
return config.entryAdminTitle
|
|
5
|
-
?
|
|
7
|
+
? String(publishedVersion.data[config.entryAdminTitle] || entry.id)
|
|
6
8
|
: entry.id;
|
|
7
9
|
}
|
|
8
|
-
|
|
10
|
+
const draftVersion = entry.draftVersions[language];
|
|
11
|
+
if (draftVersion) {
|
|
9
12
|
return config.entryAdminTitle
|
|
10
|
-
?
|
|
13
|
+
? String(draftVersion.data[config.entryAdminTitle] || entry.id)
|
|
11
14
|
: entry.id;
|
|
12
15
|
}
|
|
13
16
|
return entry.id;
|
|
14
17
|
}
|
|
15
18
|
export function getCollectionEntryLabel(entry, config) {
|
|
16
19
|
return config.entryAdminTitle
|
|
17
|
-
? getAtPath(entry
|
|
18
|
-
: entry.
|
|
20
|
+
? getAtPath(entry, config.entryAdminTitle) || entry._id
|
|
21
|
+
: entry._id;
|
|
19
22
|
}
|
|
@@ -13,7 +13,6 @@ function isFieldFilled(value, fieldType) {
|
|
|
13
13
|
return false;
|
|
14
14
|
switch (fieldType) {
|
|
15
15
|
case 'text':
|
|
16
|
-
case 'richtext':
|
|
17
16
|
return typeof value === 'string' && value.length > 0;
|
|
18
17
|
case 'content': {
|
|
19
18
|
if (typeof value !== 'object')
|
|
@@ -46,7 +45,7 @@ function collectLocalizedFields(fields) {
|
|
|
46
45
|
if (field.localized === false)
|
|
47
46
|
continue;
|
|
48
47
|
const label = extractLabel(field.label, field.slug);
|
|
49
|
-
if (field.type === 'text' || field.type === '
|
|
48
|
+
if (field.type === 'text' || field.type === 'content') {
|
|
50
49
|
result.push({ slug: field.slug, label, type: field.type, required: !!field.required });
|
|
51
50
|
}
|
|
52
51
|
}
|
|
@@ -50,6 +50,16 @@ export * from 'includio-cms/admin/remote';
|
|
|
50
50
|
</script>
|
|
51
51
|
|
|
52
52
|
<AcceptInvitePage />
|
|
53
|
+
`
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
path: 'admin/reset-password/+page.svelte',
|
|
57
|
+
content: `${GENERATED_COMMENT}
|
|
58
|
+
<script lang="ts">
|
|
59
|
+
import { ResetPasswordPage } from 'includio-cms/admin/client';
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<ResetPasswordPage />
|
|
53
63
|
`
|
|
54
64
|
},
|
|
55
65
|
{
|
|
@@ -109,13 +119,25 @@ export * from 'includio-cms/admin/remote';
|
|
|
109
119
|
`
|
|
110
120
|
},
|
|
111
121
|
{
|
|
112
|
-
path: 'admin/(afterLogin)/form-submissions/[
|
|
122
|
+
path: 'admin/(afterLogin)/form-submissions/[id]/+page.server.ts',
|
|
123
|
+
content: `${GENERATED_COMMENT_TS}
|
|
124
|
+
export async function load({ params }) {
|
|
125
|
+
return {
|
|
126
|
+
submissionId: params.id
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
`
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
path: 'admin/(afterLogin)/form-submissions/[id]/+page.svelte',
|
|
113
133
|
content: `${GENERATED_COMMENT}
|
|
114
134
|
<script lang="ts">
|
|
115
135
|
import { FormSubmissionPage } from 'includio-cms/admin/client';
|
|
136
|
+
|
|
137
|
+
let { data } = $props();
|
|
116
138
|
</script>
|
|
117
139
|
|
|
118
|
-
<FormSubmissionPage />
|
|
140
|
+
<FormSubmissionPage {data} />
|
|
119
141
|
`
|
|
120
142
|
},
|
|
121
143
|
{
|
|
@@ -138,6 +160,16 @@ export * from 'includio-cms/admin/remote';
|
|
|
138
160
|
</script>
|
|
139
161
|
|
|
140
162
|
<AccountPage />
|
|
163
|
+
`
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
path: 'admin/(afterLogin)/maintenance/+page.svelte',
|
|
167
|
+
content: `${GENERATED_COMMENT}
|
|
168
|
+
<script lang="ts">
|
|
169
|
+
import { MaintenancePage } from 'includio-cms/admin/client';
|
|
170
|
+
</script>
|
|
171
|
+
|
|
172
|
+
<MaintenancePage />
|
|
141
173
|
`
|
|
142
174
|
},
|
|
143
175
|
{
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import type { SingleEntryMap, SingleSlug, CollectionEntryMap, CollectionSlug, FormEntryMap, SiteLanguage } from './types';
|
|
2
|
-
interface
|
|
3
|
-
data: Record<string, unknown>;
|
|
4
|
-
}> {
|
|
2
|
+
interface GetEntryOptions {
|
|
5
3
|
id?: string;
|
|
6
4
|
status?: 'draft' | 'published' | 'scheduled' | 'archived';
|
|
7
|
-
dataValues?:
|
|
8
|
-
}
|
|
9
|
-
interface GetEntryOptions {
|
|
10
|
-
/**
|
|
11
|
-
* Language code to fetch the entry in. Defaults to the first language in the CMS config.
|
|
12
|
-
*/
|
|
5
|
+
dataValues?: Record<string, unknown>;
|
|
13
6
|
language?: SiteLanguage;
|
|
14
7
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
8
|
+
interface GetEntriesOptions extends GetEntryOptions {
|
|
9
|
+
ids?: string[];
|
|
10
|
+
dataLike?: Record<string, unknown>;
|
|
11
|
+
orderBy?: {
|
|
12
|
+
column: 'createdAt' | 'updatedAt' | 'sortOrder';
|
|
13
|
+
direction: 'asc' | 'desc';
|
|
14
|
+
};
|
|
15
|
+
limit?: number;
|
|
16
|
+
offset?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function getSingleEntry<K extends SingleSlug>(slug: K, options?: GetEntryOptions): Promise<SingleEntryMap[K] | null>;
|
|
19
|
+
export declare function getCollectionEntry<K extends CollectionSlug>(slug: K, options?: GetEntryOptions): Promise<CollectionEntryMap[K] | null>;
|
|
20
|
+
export declare function getCollectionEntries<K extends CollectionSlug>(slug: K, options?: GetEntriesOptions): Promise<CollectionEntryMap[K][]>;
|
|
21
|
+
export declare function countCollectionEntries<K extends CollectionSlug>(slug: K, options?: Omit<GetEntriesOptions, 'limit' | 'offset' | 'orderBy'>): Promise<number>;
|
|
18
22
|
export interface SubmitFormOptions {
|
|
19
23
|
ip?: string;
|
|
20
24
|
userAgent?: string;
|
package/dist/cms/runtime/api.js
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
// This file is auto-generated. Do not edit directly.
|
|
2
|
-
import { getEntry, getEntries, createFormSubmission } from 'includio-cms/sveltekit/server';
|
|
3
|
-
export async function getSingleEntry(slug,
|
|
2
|
+
import { getEntry, getEntries, countEntries, createFormSubmission } from 'includio-cms/sveltekit/server';
|
|
3
|
+
export async function getSingleEntry(slug, options = {}) {
|
|
4
4
|
return (await getEntry({
|
|
5
|
-
...data,
|
|
6
5
|
slug,
|
|
7
6
|
...options
|
|
8
7
|
}));
|
|
9
8
|
}
|
|
10
|
-
export async function getCollectionEntry(slug,
|
|
9
|
+
export async function getCollectionEntry(slug, options = {}) {
|
|
11
10
|
return (await getEntry({
|
|
12
|
-
...data,
|
|
13
11
|
slug,
|
|
14
12
|
...options
|
|
15
13
|
}));
|
|
16
14
|
}
|
|
17
|
-
export async function getCollectionEntries(slug, options) {
|
|
15
|
+
export async function getCollectionEntries(slug, options = {}) {
|
|
18
16
|
return (await getEntries({ slug, ...options }));
|
|
19
17
|
}
|
|
18
|
+
export async function countCollectionEntries(slug, options = {}) {
|
|
19
|
+
return countEntries({ slug, ...options });
|
|
20
|
+
}
|
|
20
21
|
export async function submitForm(slug, data, options) {
|
|
21
22
|
return createFormSubmission({ slug, data, ...options });
|
|
22
23
|
}
|