@kyro-cms/admin 0.12.6 → 0.12.8
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/dist/index.cjs +32 -110
- package/dist/index.css +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +32 -110
- package/package.json +11 -5
- package/src/components/AuthBridge.tsx +2 -2
- package/src/components/AutoForm.tsx +49 -57
- package/src/components/BrandingHub.tsx +1 -1
- package/src/components/DashboardMetrics.tsx +519 -290
- package/src/components/DetailView.tsx +5 -3
- package/src/components/FieldRenderer.tsx +59 -47
- package/src/components/ListView.tsx +25 -10
- package/src/components/MediaGallery.tsx +1 -1
- package/src/components/PluginsManager.tsx +39 -21
- package/src/components/Sidebar.astro +7 -14
- package/src/components/UserMenu.tsx +10 -9
- package/src/components/autoform/AutoFormApiView.tsx +1 -1
- package/src/components/autoform/AutoFormHeader.tsx +1 -1
- package/src/components/autoform/ErrorBoundary.tsx +1 -1
- package/src/components/blocks/AccordionBlock.tsx +8 -4
- package/src/components/blocks/ArrayBlock.tsx +4 -3
- package/src/components/blocks/BlockEditModal.tsx +4 -3
- package/src/components/blocks/CardBlock.tsx +10 -2
- package/src/components/blocks/ChildBlocksTree.tsx +19 -18
- package/src/components/blocks/CodeBlock.tsx +7 -2
- package/src/components/blocks/FileBlock.tsx +6 -2
- package/src/components/blocks/GenericBlock.tsx +1 -1
- package/src/components/blocks/HeadingBlock.tsx +6 -2
- package/src/components/blocks/HeadingSubheadingBlock.tsx +7 -2
- package/src/components/blocks/HeroBlock.tsx +12 -3
- package/src/components/blocks/ImageBlock.tsx +8 -2
- package/src/components/blocks/ListBlock.tsx +6 -2
- package/src/components/blocks/ParagraphBlock.tsx +2 -2
- package/src/components/blocks/RelationshipBlock.tsx +10 -2
- package/src/components/blocks/VideoBlock.tsx +7 -2
- package/src/components/fields/AccordionField.tsx +1 -1
- package/src/components/fields/ArrayLayout.tsx +55 -58
- package/src/components/fields/BlocksField.tsx +1 -1
- package/src/components/fields/ChildrenField.tsx +3 -2
- package/src/components/fields/CodeField.tsx +3 -2
- package/src/components/fields/ColumnsField.tsx +3 -2
- package/src/components/fields/DateField.tsx +2 -2
- package/src/components/fields/FieldLayout.tsx +3 -3
- package/src/components/fields/GroupLayout.tsx +2 -2
- package/src/components/fields/IconField.tsx +1 -1
- package/src/components/fields/MarkdownField.tsx +2 -2
- package/src/components/fields/NumberField.tsx +4 -4
- package/src/components/fields/RelationshipField.tsx +4 -1
- package/src/components/fields/RichTextField.tsx +200 -5
- package/src/components/fields/extensions/blockComponents.tsx +1 -1
- package/src/components/fields/extensions/blocksStore.ts +15 -12
- package/src/components/ui/Button.tsx +5 -2
- package/src/components/ui/Pagination.tsx +1 -1
- package/src/components/users/UserDetail.tsx +7 -7
- package/src/components/users/UserForm.tsx +3 -2
- package/src/components/users/UsersList.tsx +5 -4
- package/src/env.d.ts +4 -0
- package/src/fields/index.ts +1 -1
- package/src/hooks/useAutoFormState.ts +7 -6
- package/src/hooks/useQueue.ts +3 -2
- package/src/index.ts +0 -1
- package/src/integration.ts +50 -12
- package/src/kyro-cms.d.ts +5 -0
- package/src/lib/api.ts +1 -0
- package/src/lib/autoform-store.ts +4 -3
- package/src/lib/config.ts +6 -19
- package/src/lib/core-types.ts +78 -0
- package/src/lib/normalize-upload-fields.ts +17 -4
- package/src/pages/[collection]/index.astro +1 -1
- package/src/pages/users/index.astro +1 -1
- package/src/plugins/seo-admin.tsx +155 -0
- package/src/styles/main.css +2 -0
- package/src/vite-env.d.ts +10 -1
- package/src/components/fix_imports.cjs +0 -23
- package/src/components/fix_imports2.cjs +0 -19
- package/src/components/replace_svgs.cjs +0 -63
package/src/integration.ts
CHANGED
|
@@ -70,7 +70,7 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
|
|
|
70
70
|
sourcemap: false,
|
|
71
71
|
loader: { '.ts': 'ts', '.tsx': 'tsx' },
|
|
72
72
|
resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'],
|
|
73
|
-
external: ['@kyro-cms/*'],
|
|
73
|
+
external: ['@kyro-cms/*', '@ai-sdk/*'],
|
|
74
74
|
});
|
|
75
75
|
tmpFile = resolvedConfig.replace(/\.ts$/, ".admin.mjs");
|
|
76
76
|
fs.writeFileSync(tmpFile, result.outputFiles[0].text, "utf8");
|
|
@@ -101,6 +101,10 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
|
|
|
101
101
|
collections: serialize(cfg?.collections) || [],
|
|
102
102
|
globals: serialize(cfg?.globals) || [],
|
|
103
103
|
collectionOverrides: serialize(cfg?.admin?.collectionOverrides) || {},
|
|
104
|
+
plugins: (cfg?.plugins || []).map((p: any) => ({
|
|
105
|
+
name: p.name,
|
|
106
|
+
adminEntry: p.adminEntry
|
|
107
|
+
}))
|
|
104
108
|
});
|
|
105
109
|
}).catch(err => {
|
|
106
110
|
parentPort.postMessage({ error: err.message });
|
|
@@ -128,17 +132,21 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
|
|
|
128
132
|
{ name: "metaDescription", type: "textarea", label: "Meta Description", admin: { description: "A brief summary for search engines (recommended < 160 chars).", autoGenerate: "content" } },
|
|
129
133
|
{ name: "keywords", type: "text", label: "Keywords", admin: { description: "Comma-separated list of keywords for this page." } },
|
|
130
134
|
{ name: "ogImage", type: "upload", label: "OpenGraph Image", relationTo: "media", admin: { description: "The image shown when shared on social media." } },
|
|
131
|
-
{
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
{
|
|
136
|
+
name: "twitter", type: "group", label: "Twitter Card", fields: [
|
|
137
|
+
{ name: "title", type: "text", label: "Twitter Title" },
|
|
138
|
+
{ name: "description", type: "textarea", label: "Twitter Description" },
|
|
139
|
+
{ name: "image", type: "upload", label: "Twitter Image", relationTo: "media" },
|
|
140
|
+
]
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "advanced", type: "group", label: "Advanced Search Settings", fields: [
|
|
144
|
+
{ name: "noindex", type: "checkbox", label: "Hide from search engines (noindex)", defaultValue: false },
|
|
145
|
+
{ name: "nofollow", type: "checkbox", label: "Do not follow links (nofollow)", defaultValue: false },
|
|
146
|
+
{ name: "canonicalUrl", type: "text", label: "Canonical URL Override", admin: { description: "Leave empty to use the default canonical URL." } },
|
|
147
|
+
{ name: "structuredData", type: "code", label: "JSON-LD Structured Data", admin: { description: "Custom JSON-LD schema for this specific page." } },
|
|
148
|
+
]
|
|
149
|
+
},
|
|
142
150
|
];
|
|
143
151
|
for (const col of (configResult.collections || [])) {
|
|
144
152
|
if (col.seo) {
|
|
@@ -165,6 +173,36 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
|
|
|
165
173
|
vite: {
|
|
166
174
|
plugins: [
|
|
167
175
|
useSyncExternalStoreShimPlugin(),
|
|
176
|
+
{
|
|
177
|
+
name: "kyro-plugins-virtual",
|
|
178
|
+
resolveId(id: string) {
|
|
179
|
+
if (id === "virtual:kyro-plugins") {
|
|
180
|
+
return "\0virtual:kyro-plugins";
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
load(id: string) {
|
|
184
|
+
if (id === "\0virtual:kyro-plugins") {
|
|
185
|
+
try {
|
|
186
|
+
const configData = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
|
187
|
+
const pluginsWithAdmin = (configData.plugins || []).filter((p: any) => p.adminEntry);
|
|
188
|
+
|
|
189
|
+
let imports = `import { lazy } from 'react';\n`;
|
|
190
|
+
let views = `export const pluginViews = {\n`;
|
|
191
|
+
|
|
192
|
+
for (const p of pluginsWithAdmin) {
|
|
193
|
+
// Vite requires absolute paths for local files or bare specifiers for packages
|
|
194
|
+
views += ` '${p.name}': lazy(() => import('${p.adminEntry}')),\n`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
views += `};\n`;
|
|
198
|
+
const configExport = `export const projectConfig = ${JSON.stringify(configData)};\n`;
|
|
199
|
+
return imports + views + configExport;
|
|
200
|
+
} catch (e) {
|
|
201
|
+
return `export const pluginViews = {};\nexport const projectConfig = null;\n`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
},
|
|
168
206
|
{
|
|
169
207
|
name: "kyro-admin-tsx-loader",
|
|
170
208
|
enforce: "pre" as const,
|
package/src/kyro-cms.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ admin?: {
|
|
|
62
62
|
export interface JSONField extends FieldConfig { type: 'json' }
|
|
63
63
|
export interface ImageField extends FieldConfig { type: 'image' }
|
|
64
64
|
export interface UploadField extends FieldConfig { type: 'upload' }
|
|
65
|
+
export interface IconField extends FieldConfig { type: 'icon' }
|
|
66
|
+
export interface SecretField extends FieldConfig { type: 'secret' }
|
|
65
67
|
export interface RelationshipField extends FieldConfig { type: 'relationship'; relationTo: string }
|
|
66
68
|
export interface BlocksField extends FieldConfig { type: 'blocks'; blocks: Block[] }
|
|
67
69
|
export interface ArrayField extends FieldConfig { type: 'array'; fields: FieldConfig[] }
|
|
@@ -181,6 +183,9 @@ admin?: {
|
|
|
181
183
|
newValue?: unknown;
|
|
182
184
|
[key: string]: unknown;
|
|
183
185
|
}
|
|
186
|
+
|
|
187
|
+
export const richTextStyles: string;
|
|
188
|
+
export function renderRichText(content: unknown, options?: unknown): string;
|
|
184
189
|
}
|
|
185
190
|
|
|
186
191
|
declare module '@kyro-cms/core/client' {
|
package/src/lib/api.ts
CHANGED
|
@@ -35,6 +35,7 @@ export async function fetchWithAuth(
|
|
|
35
35
|
if (!(options.body instanceof FormData)) {
|
|
36
36
|
headers["Content-Type"] = "application/json";
|
|
37
37
|
}
|
|
38
|
+
headers["x-kyro-admin"] = "true";
|
|
38
39
|
Object.assign(headers, options.headers as Record<string, string> | undefined);
|
|
39
40
|
|
|
40
41
|
const response = await fetch(resolveApi(url), {
|
|
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from "zustand/middleware";
|
|
|
3
3
|
import type { StateStorage } from "zustand/middleware";
|
|
4
4
|
import { createStorage } from "unstorage";
|
|
5
5
|
import indexedbDriver from "unstorage/drivers/indexedb";
|
|
6
|
-
import type { Version } from "@kyro-cms/core/client";
|
|
6
|
+
import type { Version, VersionDiff } from "@kyro-cms/core/client";
|
|
7
7
|
import { deepEqual, isEmpty } from "./deep-equal";
|
|
8
8
|
import { normalizeUploadFields } from "./normalize-upload-fields";
|
|
9
9
|
|
|
@@ -247,8 +247,9 @@ export const useAutoFormStore = create<AutoFormStore>()(
|
|
|
247
247
|
if (current[keys[i]] === undefined) {
|
|
248
248
|
current[keys[i]] = {};
|
|
249
249
|
}
|
|
250
|
-
|
|
251
|
-
current
|
|
250
|
+
const nextVal = current[keys[i]] as Record<string, unknown>;
|
|
251
|
+
current[keys[i]] = { ...nextVal };
|
|
252
|
+
current = current[keys[i]] as Record<string, unknown>;
|
|
252
253
|
}
|
|
253
254
|
|
|
254
255
|
current[keys[keys.length - 1]] = value;
|
package/src/lib/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CollectionConfig, GlobalConfig } from "@kyro-cms/core/client";
|
|
2
|
+
import type { AdminConfig } from "./core-types";
|
|
2
3
|
import {
|
|
3
4
|
blogCollections,
|
|
4
5
|
ecommerceCollections,
|
|
@@ -10,11 +11,7 @@ import {
|
|
|
10
11
|
allSettingsGlobals,
|
|
11
12
|
coreSettingsGlobals,
|
|
12
13
|
} from "@kyro-cms/core/templates";
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
// Injected by Vite's define config — exact path to the serialized config JSON.
|
|
16
|
-
declare const __KYRO_ADMIN_CONFIG_FILE__: string;
|
|
17
|
-
|
|
14
|
+
import { projectConfig } from "virtual:kyro-plugins";
|
|
18
15
|
type ConfigCollectionInput =
|
|
19
16
|
| CollectionConfig[]
|
|
20
17
|
| Record<string, CollectionConfig>
|
|
@@ -255,26 +252,16 @@ function createProjectAdminConfig(config: {
|
|
|
255
252
|
};
|
|
256
253
|
}
|
|
257
254
|
|
|
258
|
-
// Read config from the
|
|
259
|
-
let cachedConfig: { collections: any[]; globals: any[]; collectionOverrides?: Record<string, any> } | null = null;
|
|
260
|
-
|
|
255
|
+
// Read config from the virtual module injected by our Vite plugin
|
|
261
256
|
function loadProjectConfig() {
|
|
262
|
-
if (
|
|
263
|
-
try {
|
|
264
|
-
if (typeof __KYRO_ADMIN_CONFIG_FILE__ === "string" && fs.existsSync(__KYRO_ADMIN_CONFIG_FILE__)) {
|
|
265
|
-
cachedConfig = JSON.parse(fs.readFileSync(__KYRO_ADMIN_CONFIG_FILE__, "utf8"));
|
|
266
|
-
return cachedConfig;
|
|
267
|
-
}
|
|
268
|
-
} catch {
|
|
269
|
-
// fall through to kitchen-sink default
|
|
270
|
-
}
|
|
257
|
+
if (projectConfig) return projectConfig;
|
|
271
258
|
return null;
|
|
272
259
|
}
|
|
273
260
|
|
|
274
261
|
const projectCfg = loadProjectConfig() || { collections: [], globals: [] };
|
|
275
262
|
|
|
276
263
|
const rawConfig = createProjectAdminConfig(projectCfg);
|
|
277
|
-
applyCollectionAdminOverrides(rawConfig.collections, projectCfg.collectionOverrides);
|
|
264
|
+
applyCollectionAdminOverrides(rawConfig.collections, (projectCfg.collectionOverrides as Record<string, any>) || {});
|
|
278
265
|
|
|
279
266
|
export const adminConfig = rawConfig;
|
|
280
267
|
export const collections = adminConfig.collections;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local type declarations for core types that TSC cannot resolve
|
|
3
|
+
* from the barrel `@kyro-cms/core/client` due to tsup's chunked
|
|
4
|
+
* .d.ts re-export format.
|
|
5
|
+
*
|
|
6
|
+
* These types mirror the definitions in the core source and are
|
|
7
|
+
* used only for compile-time type checking in the admin package.
|
|
8
|
+
* At runtime, Vite resolves the actual types correctly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Minimal BaseField shape needed for IconField/SecretField */
|
|
12
|
+
interface BaseField {
|
|
13
|
+
type: string;
|
|
14
|
+
name: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
admin?: Record<string, any>;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Field Types ---
|
|
22
|
+
|
|
23
|
+
export interface IconField extends BaseField {
|
|
24
|
+
type: "icon";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SecretField extends BaseField {
|
|
28
|
+
type: "secret";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DeclarativeCondition =
|
|
32
|
+
| {
|
|
33
|
+
field: string;
|
|
34
|
+
equals?: string | number | boolean;
|
|
35
|
+
notEquals?: string | number | boolean;
|
|
36
|
+
in?: (string | number | boolean)[];
|
|
37
|
+
greaterThan?: number;
|
|
38
|
+
}
|
|
39
|
+
| { and: DeclarativeCondition[] }
|
|
40
|
+
| { or: DeclarativeCondition[] };
|
|
41
|
+
|
|
42
|
+
// --- Config Types ---
|
|
43
|
+
|
|
44
|
+
export interface AdminConfig {
|
|
45
|
+
/** Admin panel title */
|
|
46
|
+
title?: string;
|
|
47
|
+
/** Sidebar label override */
|
|
48
|
+
label?: string;
|
|
49
|
+
/** Description shown in the admin */
|
|
50
|
+
description?: string;
|
|
51
|
+
/** Admin panel icon (lucide icon name) */
|
|
52
|
+
icon?: string;
|
|
53
|
+
/** Hide from navigation */
|
|
54
|
+
hidden?: boolean;
|
|
55
|
+
/** Default columns shown in the list view */
|
|
56
|
+
listColumns?: string[];
|
|
57
|
+
/** Default sort field */
|
|
58
|
+
defaultSort?: string;
|
|
59
|
+
/** Enable/disable search */
|
|
60
|
+
enableSearch?: boolean;
|
|
61
|
+
/** Custom admin group */
|
|
62
|
+
group?: string;
|
|
63
|
+
/** Pagination settings */
|
|
64
|
+
pagination?: {
|
|
65
|
+
defaultLimit?: number;
|
|
66
|
+
limits?: number[];
|
|
67
|
+
};
|
|
68
|
+
/** Preview URL function */
|
|
69
|
+
preview?: (doc: Record<string, any>) => string | null;
|
|
70
|
+
/** Live preview configuration */
|
|
71
|
+
livePreview?: {
|
|
72
|
+
url?: string | ((doc: Record<string, any>) => string);
|
|
73
|
+
};
|
|
74
|
+
/** Custom CSS class for the admin view */
|
|
75
|
+
className?: string;
|
|
76
|
+
/** Disable create button */
|
|
77
|
+
disableCreate?: boolean;
|
|
78
|
+
}
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
* Without normalization, the server stores the full object as a JSON string,
|
|
8
8
|
* which causes 404s on the next load when trying to fetch the media.
|
|
9
9
|
*/
|
|
10
|
-
export function normalizeUploadFields(value: unknown): unknown {
|
|
10
|
+
export function normalizeUploadFields(value: unknown, isRoot = false): unknown {
|
|
11
11
|
if (value === null || value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
12
12
|
return value;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
if (Array.isArray(value)) {
|
|
16
|
-
return value.map(normalizeUploadFields);
|
|
16
|
+
return value.map(v => normalizeUploadFields(v, false));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
if (typeof value === "object") {
|
|
@@ -25,14 +25,27 @@ export function normalizeUploadFields(value: unknown): unknown {
|
|
|
25
25
|
const hasId = "id" in obj && (typeof obj.id === "string" || obj.id === null);
|
|
26
26
|
const hasMediaField = "url" in obj && ("filename" in obj || "mimeType" in obj);
|
|
27
27
|
|
|
28
|
-
if (hasId && hasMediaField && keys.length <= 25) {
|
|
28
|
+
if (!isRoot && hasId && hasMediaField && keys.length <= 25) {
|
|
29
|
+
return obj.id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Heuristic for polymorphic relationships: { relationTo: '...', value: { id: '...' } }
|
|
33
|
+
if (!isRoot && "relationTo" in obj && "value" in obj && typeof obj.value === "object" && obj.value !== null) {
|
|
34
|
+
const valObj = obj.value as Record<string, unknown>;
|
|
35
|
+
if ("id" in valObj && (typeof valObj.id === "string" || typeof valObj.id === "number")) {
|
|
36
|
+
return { relationTo: obj.relationTo, value: valObj.id };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Heuristic for regular populated relationships: must have id, createdAt, updatedAt
|
|
41
|
+
if (!isRoot && hasId && "createdAt" in obj && "updatedAt" in obj) {
|
|
29
42
|
return obj.id;
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
// Recursively normalize nested objects (tabs, groups, blocks, arrays)
|
|
33
46
|
const result: Record<string, unknown> = {};
|
|
34
47
|
for (const key of keys) {
|
|
35
|
-
result[key] = normalizeUploadFields(obj[key]);
|
|
48
|
+
result[key] = normalizeUploadFields(obj[key], false);
|
|
36
49
|
}
|
|
37
50
|
return result;
|
|
38
51
|
}
|
|
@@ -32,7 +32,7 @@ if (!isSettings) {
|
|
|
32
32
|
|
|
33
33
|
try {
|
|
34
34
|
const response = await fetch(
|
|
35
|
-
`${Astro.url.origin}${apiPath}/${collection}?page=${page}&limit=${limit}&t=${Date.now()}`,
|
|
35
|
+
`${Astro.url.origin}${apiPath}/${collection}?page=${page}&limit=${limit}&depth=1&t=${Date.now()}`,
|
|
36
36
|
{
|
|
37
37
|
headers: Astro.request.headers,
|
|
38
38
|
credentials: "include",
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { useToast } from '../index';
|
|
3
|
+
|
|
4
|
+
export default function SeoSettings() {
|
|
5
|
+
const [loading, setLoading] = useState(true);
|
|
6
|
+
const [saving, setSaving] = useState(false);
|
|
7
|
+
const [docId, setDocId] = useState<string | null>(null);
|
|
8
|
+
|
|
9
|
+
const [sitemap, setSitemap] = useState(true);
|
|
10
|
+
const [robotsTxt, setRobotsTxt] = useState('');
|
|
11
|
+
const [canonicalUrl, setCanonicalUrl] = useState('');
|
|
12
|
+
const [ogImage, setOgImage] = useState('');
|
|
13
|
+
|
|
14
|
+
const { addToast } = useToast();
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const apiPath = (window as any).__KYRO_API_PATH__ || '/api';
|
|
18
|
+
fetch(`${apiPath}/seo-settings?limit=1`, { credentials: 'include' })
|
|
19
|
+
.then(res => res.json())
|
|
20
|
+
.then(resData => {
|
|
21
|
+
if (resData && resData.docs && resData.docs.length > 0) {
|
|
22
|
+
const doc = resData.docs[0];
|
|
23
|
+
setDocId(doc.id);
|
|
24
|
+
if (doc.sitemap !== undefined) setSitemap(doc.sitemap);
|
|
25
|
+
if (doc.robotsTxt !== undefined) setRobotsTxt(doc.robotsTxt);
|
|
26
|
+
if (doc.canonicalUrl !== undefined) setCanonicalUrl(doc.canonicalUrl);
|
|
27
|
+
if (doc.ogImage !== undefined) setOgImage(doc.ogImage);
|
|
28
|
+
}
|
|
29
|
+
setLoading(false);
|
|
30
|
+
})
|
|
31
|
+
.catch(err => {
|
|
32
|
+
console.error('Failed to load SEO settings:', err);
|
|
33
|
+
setLoading(false);
|
|
34
|
+
});
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
setSaving(true);
|
|
40
|
+
|
|
41
|
+
const apiPath = (window as any).__KYRO_API_PATH__ || '/api';
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const payload = {
|
|
45
|
+
sitemap,
|
|
46
|
+
robotsTxt,
|
|
47
|
+
canonicalUrl,
|
|
48
|
+
ogImage
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let url = `${apiPath}/seo-settings`;
|
|
52
|
+
let method = 'POST';
|
|
53
|
+
|
|
54
|
+
if (docId) {
|
|
55
|
+
url = `${url}/${docId}`;
|
|
56
|
+
method = 'PATCH';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const res = await fetch(url, {
|
|
60
|
+
method,
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify(payload),
|
|
63
|
+
credentials: 'include'
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
throw new Error('Failed to save settings');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const data = await res.json();
|
|
71
|
+
if (data.doc && !docId) {
|
|
72
|
+
setDocId(data.doc.id);
|
|
73
|
+
}
|
|
74
|
+
addToast('success', 'SEO Settings saved successfully!');
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(err);
|
|
77
|
+
addToast('error', 'Error saving settings.');
|
|
78
|
+
} finally {
|
|
79
|
+
setSaving(false);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (loading) {
|
|
84
|
+
return (
|
|
85
|
+
<div className="p-8 flex items-center justify-center animate-pulse">
|
|
86
|
+
<div className="w-8 h-8 rounded-full border-2 border-t-[var(--kyro-primary)] border-[var(--kyro-primary)]/20 animate-spin" />
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="p-4">
|
|
93
|
+
<h2 className="text-lg font-bold mb-4">SEO Configuration</h2>
|
|
94
|
+
<form onSubmit={handleSave} className="space-y-4">
|
|
95
|
+
|
|
96
|
+
{/* Sitemap Checkbox */}
|
|
97
|
+
<div className="flex items-center gap-3">
|
|
98
|
+
<input
|
|
99
|
+
type="checkbox"
|
|
100
|
+
id="sitemap"
|
|
101
|
+
checked={sitemap}
|
|
102
|
+
onChange={(e) => setSitemap(e.target.checked)}
|
|
103
|
+
className="w-4 h-4 rounded border-[var(--kyro-border)] text-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)]"
|
|
104
|
+
/>
|
|
105
|
+
<label htmlFor="sitemap" className="text-sm font-medium text-[var(--kyro-text)]">
|
|
106
|
+
Enable Sitemap Generation
|
|
107
|
+
</label>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Canonical URL */}
|
|
111
|
+
<div className="space-y-1.5">
|
|
112
|
+
<label className="text-xs font-medium text-[var(--kyro-text)]">Canonical URL</label>
|
|
113
|
+
<input
|
|
114
|
+
type="url"
|
|
115
|
+
value={canonicalUrl}
|
|
116
|
+
onChange={(e) => setCanonicalUrl(e.target.value)}
|
|
117
|
+
className="w-full px-3 py-1.5 bg-[var(--kyro-bg-secondary)] border border-[var(--kyro-border)] rounded-lg text-sm focus:border-[var(--kyro-primary)] focus:ring-1 focus:ring-[var(--kyro-primary)] outline-none transition-all"
|
|
118
|
+
placeholder="https://example.com"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Default OG Image */}
|
|
123
|
+
<div className="space-y-1.5">
|
|
124
|
+
<label className="text-xs font-medium text-[var(--kyro-text)]">Default OG Image URL</label>
|
|
125
|
+
<input
|
|
126
|
+
type="text"
|
|
127
|
+
value={ogImage}
|
|
128
|
+
onChange={(e) => setOgImage(e.target.value)}
|
|
129
|
+
className="w-full px-3 py-1.5 bg-[var(--kyro-bg-secondary)] border border-[var(--kyro-border)] rounded-lg text-sm focus:border-[var(--kyro-primary)] focus:ring-1 focus:ring-[var(--kyro-primary)] outline-none transition-all"
|
|
130
|
+
placeholder="/images/og-default.jpg"
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{/* robots.txt Content */}
|
|
135
|
+
<div className="space-y-1.5">
|
|
136
|
+
<label className="text-xs font-medium text-[var(--kyro-text)]">robots.txt Content</label>
|
|
137
|
+
<textarea
|
|
138
|
+
value={robotsTxt}
|
|
139
|
+
onChange={(e) => setRobotsTxt(e.target.value)}
|
|
140
|
+
className="w-full h-24 px-3 py-2 bg-[var(--kyro-bg-secondary)] border border-[var(--kyro-border)] rounded-lg text-sm focus:border-[var(--kyro-primary)] focus:ring-1 focus:ring-[var(--kyro-primary)] outline-none transition-all font-mono"
|
|
141
|
+
placeholder="User-agent: * Allow: /"
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<button
|
|
146
|
+
type="submit"
|
|
147
|
+
disabled={saving}
|
|
148
|
+
className="w-full py-2 mt-2 bg-[var(--kyro-primary)] text-white font-bold rounded-lg shadow-md shadow-[var(--kyro-primary)]/20 hover:opacity-90 transition-opacity disabled:opacity-50 text-sm"
|
|
149
|
+
>
|
|
150
|
+
{saving ? 'Saving...' : 'Save Settings'}
|
|
151
|
+
</button>
|
|
152
|
+
</form>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
package/src/styles/main.css
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
/* Semantic UI */
|
|
41
41
|
--kyro-border: #f3f4f6;
|
|
42
|
+
--kyro-border-strong: #d1d5db;
|
|
42
43
|
--kyro-input-bg: #ffffff;
|
|
43
44
|
--kyro-input-border: #e5e7eb;
|
|
44
45
|
|
|
@@ -91,6 +92,7 @@
|
|
|
91
92
|
--kyro-text-muted: #71717a;
|
|
92
93
|
|
|
93
94
|
--kyro-border: rgba(255, 255, 255, 0.08);
|
|
95
|
+
--kyro-border-strong: rgba(255, 255, 255, 0.18);
|
|
94
96
|
--kyro-input-bg: #1c1c1c;
|
|
95
97
|
--kyro-input-border: rgba(255, 255, 255, 0.1);
|
|
96
98
|
|
package/src/vite-env.d.ts
CHANGED
|
@@ -9,6 +9,15 @@ declare module "graphiql/graphiql.css" {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
declare module "react-image-crop/dist/ReactCrop.css" {
|
|
12
|
-
const content: string
|
|
12
|
+
const content: Record<string, string>;
|
|
13
13
|
export default content;
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
declare module "virtual:kyro-plugins" {
|
|
17
|
+
import type { LazyExoticComponent, ComponentType } from "react";
|
|
18
|
+
export const pluginViews: Record<
|
|
19
|
+
string,
|
|
20
|
+
LazyExoticComponent<ComponentType<any>>
|
|
21
|
+
>;
|
|
22
|
+
export const projectConfig: any;
|
|
23
|
+
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
|
|
3
|
-
const addImport = (file, icons, relativePath) => {
|
|
4
|
-
let content = fs.readFileSync(file, 'utf8');
|
|
5
|
-
if (!content.includes('from "' + relativePath + '"')) {
|
|
6
|
-
const importStatement = `import { ${icons.join(', ')} } from "${relativePath}";\n`;
|
|
7
|
-
content = importStatement + content;
|
|
8
|
-
fs.writeFileSync(file, content);
|
|
9
|
-
} else {
|
|
10
|
-
// If it exists, we might need to append to the existing import.
|
|
11
|
-
// To be safe and simple, let's just add a new line. TS merges imports.
|
|
12
|
-
const importStatement = `import { ${icons.join(', ')} } from "${relativePath}";\n`;
|
|
13
|
-
content = importStatement + content;
|
|
14
|
-
fs.writeFileSync(file, content);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/MediaGallery.tsx', ['Search', 'Check', 'Server'], './ui/icons');
|
|
19
|
-
addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/ui/Modal.tsx', ['X'], './icons');
|
|
20
|
-
addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/ui/PromptModal.tsx', ['X'], './icons');
|
|
21
|
-
addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/ui/SlidePanel.tsx', ['X'], './icons');
|
|
22
|
-
addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/users/UsersList.tsx', ['Plus', 'Lock', 'CheckCircle2', 'Edit2', 'Trash2', 'XCircle', 'X'], '../ui/icons');
|
|
23
|
-
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
|
|
3
|
-
const addImport = (file, icons, relativePath) => {
|
|
4
|
-
let content = fs.readFileSync(file, 'utf8');
|
|
5
|
-
if (!content.includes('from "' + relativePath + '"')) {
|
|
6
|
-
const importStatement = `import { ${icons.join(', ')} } from "${relativePath}";\n`;
|
|
7
|
-
content = importStatement + content;
|
|
8
|
-
fs.writeFileSync(file, content);
|
|
9
|
-
} else {
|
|
10
|
-
const importStatement = `import { ${icons.join(', ')} } from "${relativePath}";\n`;
|
|
11
|
-
content = importStatement + content;
|
|
12
|
-
fs.writeFileSync(file, content);
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/AuditLogsPage.tsx', ['Search'], './ui/icons');
|
|
17
|
-
addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/AutoForm.tsx', ['ChevronRight', 'Check', 'ExternalLink', 'X'], './ui/icons');
|
|
18
|
-
addImport('/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components/ListView.tsx', ['Search', 'Filter', 'Columns3', 'X', 'Trash2', 'Archive', 'ChevronUp', 'Edit2'], './ui/icons');
|
|
19
|
-
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
|
|
4
|
-
const dir = '/Users/macbook/Dev/Web/Astro/kyro-cms/admin/src/components';
|
|
5
|
-
const getFiles = (dir) => {
|
|
6
|
-
const dirents = fs.readdirSync(dir, { withFileTypes: true });
|
|
7
|
-
const files = dirents.map((dirent) => {
|
|
8
|
-
const res = path.resolve(dir, dirent.name);
|
|
9
|
-
return dirent.isDirectory() ? getFiles(res) : res;
|
|
10
|
-
});
|
|
11
|
-
return Array.prototype.concat(...files).filter(f => f.endsWith('.tsx'));
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const files = getFiles(dir);
|
|
15
|
-
|
|
16
|
-
const replacements = [
|
|
17
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M12 4v16m8-8H4"[^>]*\/>\s*<\/svg>/g, replace: '<Plus className="w-4 h-4" />', icon: 'Plus' },
|
|
18
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"[^>]*\/>\s*<\/svg>/g, replace: '<Upload className="w-4 h-4" />', icon: 'Upload' },
|
|
19
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"[^>]*\/>\s*<\/svg>/g, replace: '<Copy className="w-4 h-4" />', icon: 'Copy' },
|
|
20
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M19 7l-\.867 12\.142A2 2 0 0116\.138 21H7\.862a2 2 0 01-1\.995-1\.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"[^>]*\/>\s*<\/svg>/g, replace: '<Trash2 className="w-4 h-4" />', icon: 'Trash2' },
|
|
21
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"[^>]*\/>\s*<\/svg>/g, replace: '<Lock className="w-4 h-4" />', icon: 'Lock' },
|
|
22
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M10 18a8 8 0 100-16 8 8 0 000 16zm3\.707-9\.293a1 1 0 00-1\.414-1\.414L9 10\.586 7\.707 9\.293a1 1 0 00-1\.414 1\.414l2 2a1 1 0 001\.414 0l4-4z"[^>]*\/>\s*<\/svg>/g, replace: '<CheckCircle2 className="w-4 h-4" />', icon: 'CheckCircle2' },
|
|
23
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1\.414-9\.414a2 2 0 112\.828 2\.828L11\.828 15H9v-2\.828l8\.586-8\.586z"[^>]*\/>\s*<\/svg>/g, replace: '<Edit2 className="w-4 h-4" />', icon: 'Edit2' },
|
|
24
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M10 18a8 8 0 100-16 8 8 0 000 16zM8\.707 7\.293a1 1 0 00-1\.414 1\.414L8\.586 10l-1\.293 1\.293a1 1 0 101\.414 1\.414L10 11\.414l1\.293 1\.293a1 1 0 001\.414-1\.414L11\.414 10l1\.293-1\.293a1 1 0 00-1\.414-1\.414L10 8\.586 8\.707 7\.293z"[^>]*\/>\s*<\/svg>/g, replace: '<XCircle className="w-4 h-4" />', icon: 'XCircle' },
|
|
25
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M6 18L18 6M6 6l12 12"[^>]*\/>\s*<\/svg>/g, replace: '<X className="w-4 h-4" />', icon: 'X' },
|
|
26
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"[^>]*\/>\s*<\/svg>/g, replace: '<Search className="w-4 h-4" />', icon: 'Search' },
|
|
27
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M5 13l4 4L19 7"[^>]*\/>\s*<\/svg>/g, replace: '<Check className="w-4 h-4" />', icon: 'Check' },
|
|
28
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2\.586a1 1 0 01-\.293\.707l-6\.414 6\.414a1 1 0 00-\.293\.707V17l-4 4v-6\.586a1 1 0 00-\.293-\.707L3\.293 7\.293A1 1 0 013 6\.586V4z"[^>]*\/>\s*<\/svg>/g, replace: '<Filter className="w-4 h-4" />', icon: 'Filter' },
|
|
29
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"[^>]*\/>\s*<\/svg>/g, replace: '<Columns3 className="w-4 h-4" />', icon: 'Columns3' },
|
|
30
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M12 5v14M5 12h14"[^>]*\/>\s*<\/svg>/g, replace: '<Plus className="w-4 h-4" />', icon: 'Plus' },
|
|
31
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"[^>]*\/>\s*<\/svg>/g, replace: '<Archive className="w-4 h-4" />', icon: 'Archive' },
|
|
32
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M5 15l7-7 7 7"[^>]*\/>\s*<\/svg>/g, replace: '<ChevronUp className="w-4 h-4" />', icon: 'ChevronUp' },
|
|
33
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M15 19l-7-7 7-7"[^>]*\/>\s*<\/svg>/g, replace: '<ChevronRight className="w-4 h-4" />', icon: 'ChevronRight' },
|
|
34
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M20 6L9 17l-5-5"[^>]*\/>\s*<\/svg>/g, replace: '<Check className="w-4 h-4" />', icon: 'Check' },
|
|
35
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"[^>]*\/>\s*<\/svg>/g, replace: '<ExternalLink className="w-4 h-4" />', icon: 'ExternalLink' },
|
|
36
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"[^>]*\/>\s*<\/svg>/g, replace: '<File className="w-4 h-4" />', icon: 'File' },
|
|
37
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"[^>]*\/>\s*<\/svg>/g, replace: '<Clipboard className="w-4 h-4" />', icon: 'Clipboard' },
|
|
38
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M18 6L6 18M6 6l12 12"[^>]*\/>\s*<\/svg>/g, replace: '<X className="w-4 h-4" />', icon: 'X' },
|
|
39
|
-
{ match: /<svg[^>]*>\s*<path[^>]*d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2H5a2 2 0 00-2 2v1m2 2a2 2 0 11-4 0 2 2 0 014 0zm2 2h\.008v\.008H5v-\.008z"[^>]*\/>\s*<\/svg>/g, replace: '<Server className="w-8 h-8 text-[var(--kyro-sidebar-text-active)]" />', icon: 'Server' }
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
const results = {};
|
|
43
|
-
|
|
44
|
-
for (const file of files) {
|
|
45
|
-
let content = fs.readFileSync(file, 'utf8');
|
|
46
|
-
let changed = false;
|
|
47
|
-
let addedIcons = new Set();
|
|
48
|
-
|
|
49
|
-
for (const r of replacements) {
|
|
50
|
-
if (content.match(r.match)) {
|
|
51
|
-
content = content.replace(r.match, r.replace);
|
|
52
|
-
addedIcons.add(r.icon);
|
|
53
|
-
changed = true;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (changed) {
|
|
58
|
-
results[file] = Array.from(addedIcons);
|
|
59
|
-
fs.writeFileSync(file, content);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
console.log(JSON.stringify(results, null, 2));
|