@kyro-cms/admin 0.1.6 → 0.1.7
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/README.md +149 -51
- package/package.json +53 -6
- package/src/collections/auth/index.ts +2 -2
- package/src/collections/portfolio/index.ts +343 -0
- package/src/components/ActionBar.tsx +153 -16
- package/src/components/Admin.tsx +136 -27
- package/src/components/ApiExplorer.tsx +325 -0
- package/src/components/ApiKeysManager.tsx +563 -0
- package/src/components/AuditLogsPage.tsx +664 -0
- package/src/components/AutoForm.tsx +1417 -661
- package/src/components/BrandingHub.tsx +267 -0
- package/src/components/BulkActionsBar.tsx +3 -3
- package/src/components/CreateView.tsx +3 -3
- package/src/components/Dashboard.tsx +393 -0
- package/src/components/DetailView.tsx +199 -57
- package/src/components/DeveloperCenter.tsx +403 -0
- package/src/components/EnhancedListView.tsx +786 -0
- package/src/components/GraphQLExplorer.tsx +675 -0
- package/src/components/GraphQLPlayground.tsx +627 -0
- package/src/components/ListView.tsx +191 -53
- package/src/components/MediaGallery.tsx +1569 -0
- package/src/components/Modal.tsx +149 -0
- package/src/components/RestPlayground.tsx +951 -0
- package/src/components/Sidebar.astro +237 -0
- package/src/components/UserManagement.tsx +204 -0
- package/src/components/VersionHistoryPanel.tsx +3 -3
- package/src/components/WebhookManager.tsx +608 -0
- package/src/components/blocks/AccordionBlock.tsx +97 -0
- package/src/components/blocks/ArrayBlock.tsx +75 -0
- package/src/components/blocks/BlockEditModal.MARKER +12 -0
- package/src/components/blocks/BlockEditModal.tsx +774 -0
- package/src/components/blocks/ButtonBlock.tsx +165 -0
- package/src/components/blocks/ChildBlocksTree.tsx +551 -0
- package/src/components/blocks/CodeBlock.tsx +66 -0
- package/src/components/blocks/ColumnsBlock.tsx +151 -0
- package/src/components/blocks/DividerBlock.tsx +43 -0
- package/src/components/blocks/FileBlock.tsx +64 -0
- package/src/components/blocks/HeadingBlock.tsx +81 -0
- package/src/components/blocks/HeroBlock.tsx +157 -0
- package/src/components/blocks/ImageBlock.tsx +83 -0
- package/src/components/blocks/LinkBlock.tsx +71 -0
- package/src/components/blocks/ListBlock.tsx +39 -0
- package/src/components/blocks/ParagraphBlock.tsx +61 -0
- package/src/components/blocks/RelationshipBlock.tsx +279 -0
- package/src/components/blocks/VStackBlock.tsx +75 -0
- package/src/components/blocks/VideoBlock.tsx +45 -0
- package/src/components/blocks/index.ts +10 -0
- package/src/components/fields/BlocksField.tsx +323 -0
- package/src/components/fields/CheckboxField.tsx +15 -9
- package/src/components/fields/CodeField.tsx +234 -0
- package/src/components/fields/DateField.tsx +38 -11
- package/src/components/fields/EditorClient.tsx +271 -0
- package/src/components/fields/FileField.tsx +390 -0
- package/src/components/fields/HybridContentField.tsx +109 -0
- package/src/components/fields/ImageField.tsx +429 -0
- package/src/components/fields/JSONField.tsx +361 -0
- package/src/components/fields/MarkdownField.tsx +282 -0
- package/src/components/fields/NumberField.tsx +42 -12
- package/src/components/fields/PortableTextField.tsx +143 -0
- package/src/components/fields/PortableTextRenderer.tsx +68 -0
- package/src/components/fields/RelationshipField.tsx +231 -59
- package/src/components/fields/SelectField.tsx +25 -15
- package/src/components/fields/TextField.tsx +45 -14
- package/src/components/fields/extensions/blockComponents.tsx +237 -0
- package/src/components/fields/extensions/blocksStore.ts +273 -0
- package/src/components/fields/index.ts +13 -0
- package/src/components/index.ts +1 -2
- package/src/components/layout/Header.tsx +2 -2
- package/src/components/layout/Layout.tsx +2 -2
- package/src/components/ui/Badge.tsx +9 -4
- package/src/components/ui/BlockDrawer.tsx +79 -0
- package/src/components/ui/Button.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +362 -0
- package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
- package/src/components/ui/Dropdown.tsx +1 -1
- package/src/components/ui/Modal.tsx +37 -12
- package/src/components/ui/PromptModal.tsx +94 -0
- package/src/components/ui/SlidePanel.tsx +43 -16
- package/src/components/ui/Toast.tsx +80 -14
- package/src/env.d.ts +16 -0
- package/src/env.ts +20 -0
- package/src/index.ts +0 -1
- package/src/layouts/AdminLayout.astro +164 -170
- package/src/layouts/AuthLayout.astro +23 -6
- package/src/lib/MediaService.ts +541 -0
- package/src/lib/auth/sqlite-adapter.ts +319 -0
- package/src/lib/config.ts +22 -6
- package/src/lib/dataStore.ts +132 -74
- package/src/lib/db/adapter.ts +54 -0
- package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
- package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
- package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
- package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
- package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
- package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
- package/src/lib/db/index.ts +449 -0
- package/src/lib/db/mongodb-adapter.ts +207 -0
- package/src/lib/db/mongodb-auth-adapter.ts +305 -0
- package/src/lib/db/schema/mysql-auth.ts +113 -0
- package/src/lib/db/schema/mysql-content.ts +20 -0
- package/src/lib/db/schema/postgres-auth.ts +116 -0
- package/src/lib/db/schema/postgres-content.ts +35 -0
- package/src/lib/db/schema/postgres-media.ts +52 -0
- package/src/lib/db/schema/postgres-settings.ts +11 -0
- package/src/lib/db/schema/sqlite-auth.ts +112 -0
- package/src/lib/db/schema/sqlite-content.ts +20 -0
- package/src/lib/graphql/index.ts +1 -0
- package/src/lib/graphql/schema.ts +443 -0
- package/src/lib/rate-limit.ts +267 -0
- package/src/lib/storage.ts +374 -0
- package/src/lib/store.ts +85 -0
- package/src/middleware.ts +70 -11
- package/src/pages/[collection]/[id].astro +178 -122
- package/src/pages/[collection]/index.astro +24 -156
- package/src/pages/admin/api-explorer.astro +98 -0
- package/src/pages/admin/graphql-explorer.astro +40 -0
- package/src/pages/admin/graphql.astro +97 -0
- package/src/pages/admin/index.astro +200 -139
- package/src/pages/admin/keys.astro +8 -0
- package/src/pages/admin/rest-playground.astro +44 -0
- package/src/pages/admin/webhooks.astro +8 -0
- package/src/pages/api/[collection]/[id]/publish.ts +44 -0
- package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
- package/src/pages/api/[collection]/[id]/versions.ts +36 -0
- package/src/pages/api/[collection]/[id].ts +102 -159
- package/src/pages/api/[collection]/index.ts +151 -230
- package/src/pages/api/auth/[id].ts +48 -69
- package/src/pages/api/auth/audit-logs.ts +20 -43
- package/src/pages/api/auth/login.ts +159 -45
- package/src/pages/api/auth/logout.ts +42 -24
- package/src/pages/api/auth/refresh.ts +119 -0
- package/src/pages/api/auth/register.ts +110 -40
- package/src/pages/api/auth/users.ts +22 -97
- package/src/pages/api/collections.ts +59 -0
- package/src/pages/api/globals/[slug]/test.ts +172 -0
- package/src/pages/api/globals/[slug].ts +42 -0
- package/src/pages/api/graphql.ts +90 -0
- package/src/pages/api/health.ts +417 -40
- package/src/pages/api/keys/[id].ts +26 -0
- package/src/pages/api/keys/index.ts +75 -0
- package/src/pages/api/media/[id].ts +309 -0
- package/src/pages/api/media/folders.ts +609 -0
- package/src/pages/api/media/index.ts +146 -0
- package/src/pages/api/media/resize.ts +267 -0
- package/src/pages/api/search.ts +82 -0
- package/src/pages/api/slug-availability.ts +70 -0
- package/src/pages/api/storage-config.ts +20 -0
- package/src/pages/api/storage-status.ts +206 -0
- package/src/pages/api/upload.ts +334 -0
- package/src/pages/api/webhooks/index.ts +71 -0
- package/src/pages/audit/index.astro +2 -104
- package/src/pages/login.astro +11 -11
- package/src/pages/media.astro +10 -0
- package/src/pages/preview/[collection]/[id].astro +178 -0
- package/src/pages/register.astro +13 -13
- package/src/pages/roles/index.astro +21 -21
- package/src/pages/settings/[slug].astro +162 -0
- package/src/pages/settings/index.astro +9 -0
- package/src/pages/users/[id].astro +29 -21
- package/src/pages/users/index.astro +22 -17
- package/src/pages/users/new.astro +18 -17
- package/src/styles/main.css +553 -128
- package/src/components/layout/Sidebar.tsx +0 -497
|
@@ -1,144 +1,656 @@
|
|
|
1
1
|
import { useState, useRef, useEffect } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
CollectionConfig,
|
|
4
|
+
GlobalConfig,
|
|
5
|
+
Field,
|
|
6
|
+
Block,
|
|
7
|
+
} from "@kyro-cms/core";
|
|
8
|
+
import { ImageField } from "./fields/ImageField";
|
|
9
|
+
import { PortableTextField, HybridContentField } from "./fields";
|
|
10
|
+
import NumberField from "./fields/NumberField";
|
|
11
|
+
import CheckboxField from "./fields/CheckboxField";
|
|
12
|
+
import SelectField from "./fields/SelectField";
|
|
13
|
+
import DateField from "./fields/DateField";
|
|
14
|
+
import { MarkdownField } from "./fields/MarkdownField";
|
|
15
|
+
import CodeMirror from "@uiw/react-codemirror";
|
|
16
|
+
import { json } from "@codemirror/lang-json";
|
|
17
|
+
import { javascript } from "@codemirror/lang-javascript";
|
|
18
|
+
import { aura } from "@uiw/codemirror-theme-aura";
|
|
19
|
+
import { globals, collections } from "@/lib/config";
|
|
20
|
+
|
|
21
|
+
import { BlocksField } from "./fields/BlocksField";
|
|
22
|
+
// Lightweight slugify helper for on-the-fly slug generation from a title/name
|
|
23
|
+
function slugifyText(text: string): string {
|
|
24
|
+
if (!text) return "";
|
|
25
|
+
return text
|
|
26
|
+
.toString()
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.trim()
|
|
29
|
+
.replace(/\s+/g, "-") // Replace spaces with -
|
|
30
|
+
.replace(/[^\w-]+/g, "") // Remove all non-word chars
|
|
31
|
+
.replace(/--+/g, "-") // Replace multiple - with single -
|
|
32
|
+
.replace(/^-+/, "") // Trim - from start of text
|
|
33
|
+
.replace(/-+$/, ""); // Trim - from end of text
|
|
34
|
+
}
|
|
3
35
|
|
|
4
36
|
interface AutoFormProps {
|
|
5
|
-
config: CollectionConfig;
|
|
37
|
+
config: CollectionConfig | GlobalConfig;
|
|
6
38
|
data?: Record<string, any>;
|
|
7
39
|
errors?: Record<string, string>;
|
|
8
40
|
onChange?: (data: Record<string, any>) => void;
|
|
9
41
|
disabled?: boolean;
|
|
10
42
|
collectionSlug?: string;
|
|
43
|
+
globalSlug?: string;
|
|
44
|
+
documentName?: string;
|
|
45
|
+
layout?: "split" | "single";
|
|
46
|
+
onActionSuccess?: (message: string) => void;
|
|
47
|
+
onActionError?: (message: string) => void;
|
|
48
|
+
documentStatus?: "draft" | "published";
|
|
49
|
+
justSaved?: boolean;
|
|
11
50
|
}
|
|
12
51
|
|
|
13
52
|
export function AutoForm({
|
|
14
|
-
config,
|
|
15
|
-
data = {},
|
|
53
|
+
config: propConfig,
|
|
54
|
+
data: initialData = {},
|
|
16
55
|
errors = {},
|
|
17
56
|
onChange,
|
|
18
|
-
disabled,
|
|
57
|
+
disabled: propDisabled,
|
|
58
|
+
collectionSlug,
|
|
59
|
+
globalSlug,
|
|
60
|
+
documentName,
|
|
61
|
+
layout = "split",
|
|
62
|
+
onActionSuccess,
|
|
63
|
+
onActionError,
|
|
64
|
+
documentStatus,
|
|
65
|
+
justSaved,
|
|
19
66
|
}: AutoFormProps) {
|
|
67
|
+
// Resolve the "live" config to preserve functions (admin.condition) lost during prop serialization
|
|
68
|
+
const activeConfig = globalSlug
|
|
69
|
+
? globals[globalSlug]
|
|
70
|
+
: collectionSlug
|
|
71
|
+
? collections[collectionSlug]
|
|
72
|
+
: propConfig;
|
|
73
|
+
const config = activeConfig || propConfig;
|
|
74
|
+
|
|
75
|
+
// Helper to extract default values from config recursively
|
|
76
|
+
function getDefaults(fields: any[], prefix = ""): Record<string, any> {
|
|
77
|
+
const defaults: Record<string, any> = {};
|
|
78
|
+
for (const field of fields || []) {
|
|
79
|
+
if (field.defaultValue !== undefined) {
|
|
80
|
+
const key = prefix + field.name;
|
|
81
|
+
defaults[key] = field.defaultValue;
|
|
82
|
+
// Also set nested defaults for groups
|
|
83
|
+
if (field.type === "group" && field.fields) {
|
|
84
|
+
for (const subField of field.fields) {
|
|
85
|
+
if (subField.defaultValue !== undefined) {
|
|
86
|
+
defaults[prefix + field.name + "." + subField.name] =
|
|
87
|
+
subField.defaultValue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (field.fields && Array.isArray(field.fields)) {
|
|
93
|
+
Object.assign(defaults, getDefaults(field.fields, field.name + "."));
|
|
94
|
+
}
|
|
95
|
+
if (field.tabs) {
|
|
96
|
+
for (const tab of field.tabs) {
|
|
97
|
+
if (tab.fields) {
|
|
98
|
+
Object.assign(defaults, getDefaults(tab.fields, prefix));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return defaults;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Helper to flatten nested object with dot notation keys
|
|
107
|
+
function flattenObject(
|
|
108
|
+
obj: Record<string, any>,
|
|
109
|
+
prefix = "",
|
|
110
|
+
): Record<string, any> {
|
|
111
|
+
const result: Record<string, any> = {};
|
|
112
|
+
for (const key in obj) {
|
|
113
|
+
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
114
|
+
const val = obj[key];
|
|
115
|
+
if (
|
|
116
|
+
val !== null &&
|
|
117
|
+
typeof val === "object" &&
|
|
118
|
+
!Array.isArray(val) &&
|
|
119
|
+
// Only recurse into plain objects, not Dates, Maps, or other class instances
|
|
120
|
+
(val.constructor === Object || !val.constructor)
|
|
121
|
+
) {
|
|
122
|
+
Object.assign(result, flattenObject(val, newKey));
|
|
123
|
+
} else {
|
|
124
|
+
result[newKey] = val;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Helper to unflatten dot notation keys back to nested object
|
|
131
|
+
function unflattenObject(flat: Record<string, any>): Record<string, any> {
|
|
132
|
+
const result: Record<string, any> = {};
|
|
133
|
+
for (const key in flat) {
|
|
134
|
+
const parts = key.split(".");
|
|
135
|
+
let current = result;
|
|
136
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
137
|
+
if (!current[parts[i]]) {
|
|
138
|
+
current[parts[i]] = {};
|
|
139
|
+
}
|
|
140
|
+
current = current[parts[i]];
|
|
141
|
+
}
|
|
142
|
+
current[parts[parts.length - 1]] = flat[key];
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Merge initial data with defaults from config
|
|
148
|
+
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
try {
|
|
152
|
+
const configDefaults = config ? getDefaults(config.fields) : {};
|
|
153
|
+
const flatInitialData = flattenObject(initialData || {});
|
|
154
|
+
const mergedFlatData = { ...configDefaults, ...flatInitialData };
|
|
155
|
+
const mergedInitialData = unflattenObject(mergedFlatData);
|
|
156
|
+
setFormData(mergedInitialData);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
console.error("Critical error in AutoForm data initialization:", e);
|
|
159
|
+
// Fallback to raw initialData if flattening fails
|
|
160
|
+
setFormData(initialData || {});
|
|
161
|
+
}
|
|
162
|
+
}, [initialData, config]);
|
|
163
|
+
const [activeTab, setActiveTab] = useState(0);
|
|
164
|
+
const [isSlugLocked, setIsSlugLocked] = useState(true);
|
|
165
|
+
const [view, setView] = useState<"edit" | "version" | "api">("edit");
|
|
166
|
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
167
|
+
const [versions, setVersions] = useState<any[]>([]);
|
|
168
|
+
const [loadingVersions, setLoadingVersions] = useState(false);
|
|
169
|
+
const [showPreview, setShowPreview] = useState(false);
|
|
170
|
+
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
171
|
+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
172
|
+
const [loadingFields, setLoadingFields] = useState<Record<string, boolean>>(
|
|
173
|
+
{},
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const disabled = propDisabled;
|
|
177
|
+
|
|
178
|
+
// Track unsaved changes
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const isDifferent =
|
|
181
|
+
JSON.stringify(formData) !== JSON.stringify(initialData);
|
|
182
|
+
setHasUnsavedChanges(isDifferent);
|
|
183
|
+
}, [formData, initialData]);
|
|
184
|
+
|
|
185
|
+
// Auto-generate slug from title if locked
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (isSlugLocked && formData.title) {
|
|
188
|
+
const newSlug = slugifyText(formData.title);
|
|
189
|
+
if (newSlug !== formData.slug) {
|
|
190
|
+
setFormData((prev) => ({ ...prev, slug: newSlug }));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}, [formData.title, isSlugLocked]);
|
|
194
|
+
|
|
195
|
+
// Sync prop changes to local state
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (initialData && Object.keys(initialData).length > 0) {
|
|
198
|
+
setFormData(initialData);
|
|
199
|
+
}
|
|
200
|
+
}, [initialData]);
|
|
201
|
+
|
|
202
|
+
// Sync to hidden input for Astro form submission
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const hiddenInput = document.getElementById(
|
|
205
|
+
"form-data",
|
|
206
|
+
) as HTMLInputElement;
|
|
207
|
+
if (hiddenInput) {
|
|
208
|
+
hiddenInput.value = JSON.stringify(formData);
|
|
209
|
+
}
|
|
210
|
+
onChange?.(formData);
|
|
211
|
+
}, [formData, onChange]);
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (formData.id) fetchVersions();
|
|
215
|
+
}, [formData.id]);
|
|
216
|
+
|
|
217
|
+
const fetchVersions = async () => {
|
|
218
|
+
setLoadingVersions(true);
|
|
219
|
+
try {
|
|
220
|
+
const resp = await fetch(
|
|
221
|
+
`/api/${collectionSlug}/${formData.id}/versions`,
|
|
222
|
+
);
|
|
223
|
+
const data = await resp.json();
|
|
224
|
+
setVersions(data.docs || []);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error("Failed to fetch versions:", e);
|
|
227
|
+
} finally {
|
|
228
|
+
setLoadingVersions(false);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const handleRestoreVersion = async (versionId: string) => {
|
|
233
|
+
if (
|
|
234
|
+
!confirm(
|
|
235
|
+
"Are you sure you want to restore this version? This will overwrite your current changes.",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return;
|
|
239
|
+
try {
|
|
240
|
+
const resp = await fetch(
|
|
241
|
+
`/api/${collectionSlug}/${formData.id}/versions`,
|
|
242
|
+
{
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
body: JSON.stringify({ versionId, action: "restore" }),
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
const result = await resp.json();
|
|
249
|
+
if (result.data) {
|
|
250
|
+
setFormData(result.data);
|
|
251
|
+
setView("edit");
|
|
252
|
+
fetchVersions();
|
|
253
|
+
}
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.error("Restore failed:", e);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
const handleShortcuts = (e: KeyboardEvent) => {
|
|
261
|
+
// Cmd/Ctrl + S = Publish
|
|
262
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
(document.getElementById("btn-save") as any)?.click();
|
|
265
|
+
}
|
|
266
|
+
// Cmd/Ctrl + P = Toggle Preview
|
|
267
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "p") {
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
setShowPreview((prev) => !prev);
|
|
270
|
+
}
|
|
271
|
+
// Keys 1, 2, 3 = Tab Switching
|
|
272
|
+
if (
|
|
273
|
+
document.activeElement?.tagName !== "INPUT" &&
|
|
274
|
+
document.activeElement?.tagName !== "TEXTAREA"
|
|
275
|
+
) {
|
|
276
|
+
if (e.key === "1") setView("edit");
|
|
277
|
+
if (e.key === "2") setView("version");
|
|
278
|
+
if (e.key === "3") setView("api");
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
window.addEventListener("keydown", handleShortcuts);
|
|
282
|
+
return () => window.removeEventListener("keydown", handleShortcuts);
|
|
283
|
+
}, []);
|
|
284
|
+
|
|
20
285
|
const handleFieldChange = (fieldName: string, value: any) => {
|
|
21
|
-
|
|
22
|
-
...
|
|
286
|
+
setFormData((prev) => ({
|
|
287
|
+
...prev,
|
|
23
288
|
[fieldName]: value,
|
|
24
|
-
});
|
|
289
|
+
}));
|
|
25
290
|
};
|
|
26
291
|
|
|
27
|
-
const renderField = (
|
|
292
|
+
const renderField = (
|
|
293
|
+
field: Field,
|
|
294
|
+
parentData?: Record<string, any>,
|
|
295
|
+
onParentChange?: (val: any) => void,
|
|
296
|
+
): React.ReactNode => {
|
|
28
297
|
if (field.admin?.hidden) return null;
|
|
29
298
|
|
|
30
|
-
const
|
|
31
|
-
|
|
299
|
+
const currentData = parentData !== undefined ? parentData : formData;
|
|
300
|
+
|
|
301
|
+
// Evaluate display condition if present
|
|
302
|
+
// For conditional fields, pass formData as the root context (first arg)
|
|
303
|
+
// and currentData as the sibling context (second arg)
|
|
304
|
+
if (field.admin?.condition && typeof field.admin.condition === "function") {
|
|
305
|
+
try {
|
|
306
|
+
const shouldShow = field.admin.condition(formData, currentData);
|
|
307
|
+
if (!shouldShow) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
} catch (e) {
|
|
311
|
+
console.warn(`Condition error for field ${field.name}:`, e);
|
|
312
|
+
// Show the field if there's an error evaluating the condition
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const value = currentData[field.name!];
|
|
32
317
|
const error = errors[field.name!];
|
|
33
318
|
|
|
319
|
+
const onFieldChange = (val: any) => {
|
|
320
|
+
if (onParentChange) {
|
|
321
|
+
onParentChange({ ...currentData, [field.name!]: val });
|
|
322
|
+
} else {
|
|
323
|
+
handleFieldChange(field.name!, val);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
34
327
|
if (field.type === "row" && "fields" in field) {
|
|
35
328
|
return (
|
|
36
329
|
<div
|
|
37
330
|
key={field.name || `row-${Math.random()}`}
|
|
38
|
-
className="kyro-form-row"
|
|
331
|
+
className="kyro-form-row flex gap-6 items-end"
|
|
39
332
|
>
|
|
40
|
-
{(field as any).fields.map((f: Field) =>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
333
|
+
{(field as any).fields.map((f: Field) => {
|
|
334
|
+
const fAdmin = f.admin;
|
|
335
|
+
const actionUrl = fAdmin?.action;
|
|
44
336
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
{(field as any).label}
|
|
63
|
-
</summary>
|
|
64
|
-
<div className="kyro-form-collapsible-content">
|
|
65
|
-
{(field as any).fields.map((f: Field) =>
|
|
66
|
-
renderField(f, parentData),
|
|
67
|
-
)}
|
|
68
|
-
</div>
|
|
69
|
-
</details>
|
|
70
|
-
);
|
|
71
|
-
}
|
|
337
|
+
if (f.type === "button" && actionUrl) {
|
|
338
|
+
const siblingEmailField = (field as any).fields?.find(
|
|
339
|
+
(ff: Field) => ff.type === "email",
|
|
340
|
+
);
|
|
341
|
+
return (
|
|
342
|
+
<div key={f.name} className="flex-shrink-0">
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
disabled={disabled}
|
|
346
|
+
onClick={async () => {
|
|
347
|
+
const rowName = field.name;
|
|
348
|
+
const emailFieldName = siblingEmailField?.name;
|
|
349
|
+
let emailValue = formData[emailFieldName];
|
|
350
|
+
if (!emailValue && rowName) {
|
|
351
|
+
emailValue = formData[rowName]?.[emailFieldName];
|
|
352
|
+
}
|
|
353
|
+
if (!emailValue) return;
|
|
72
354
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
355
|
+
setLoadingFields((prev) => ({
|
|
356
|
+
...prev,
|
|
357
|
+
[f.name!]: true,
|
|
358
|
+
}));
|
|
359
|
+
try {
|
|
360
|
+
const response = await fetch(actionUrl, {
|
|
361
|
+
method: fAdmin.method || "POST",
|
|
362
|
+
headers: { "Content-Type": "application/json" },
|
|
363
|
+
body: JSON.stringify({ email: emailValue }),
|
|
364
|
+
});
|
|
365
|
+
let result;
|
|
366
|
+
try {
|
|
367
|
+
result = await response.json();
|
|
368
|
+
} catch {
|
|
369
|
+
result = {};
|
|
370
|
+
}
|
|
371
|
+
if (response.ok && result.success) {
|
|
372
|
+
onActionSuccess?.(
|
|
373
|
+
result.message || "Action completed successfully",
|
|
374
|
+
);
|
|
375
|
+
} else {
|
|
376
|
+
const errorMsg =
|
|
377
|
+
result.error ||
|
|
378
|
+
`Request failed (${response.status})`;
|
|
379
|
+
onActionError?.(errorMsg);
|
|
380
|
+
}
|
|
381
|
+
} catch (err: any) {
|
|
382
|
+
onActionError?.(
|
|
383
|
+
err.message || "Error connecting to server",
|
|
384
|
+
);
|
|
385
|
+
} finally {
|
|
386
|
+
setLoadingFields((prev) => ({
|
|
387
|
+
...prev,
|
|
388
|
+
[f.name!]: false,
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
}}
|
|
392
|
+
disabled={loadingFields[f.name!] || disabled}
|
|
393
|
+
className="bg-[var(--kyro-primary)] text-white px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
394
|
+
>
|
|
395
|
+
{loadingFields[f.name!] ? "Sending..." : f.label || "Click"}
|
|
396
|
+
</button>
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<div
|
|
403
|
+
key={f.name}
|
|
404
|
+
className={f.type === "button" ? "flex-shrink-0" : "flex-1"}
|
|
405
|
+
style={
|
|
406
|
+
fAdmin?.width ? { width: fAdmin.width, flex: "none" } : {}
|
|
407
|
+
}
|
|
408
|
+
>
|
|
409
|
+
{renderField(f, parentData, onParentChange)}
|
|
84
410
|
</div>
|
|
85
|
-
|
|
86
|
-
)
|
|
411
|
+
);
|
|
412
|
+
})}
|
|
87
413
|
</div>
|
|
88
414
|
);
|
|
89
415
|
}
|
|
90
416
|
|
|
91
417
|
switch (field.type) {
|
|
92
|
-
case "
|
|
93
|
-
|
|
418
|
+
case "tabs": {
|
|
419
|
+
const fieldTabs = (field as any).tabs;
|
|
420
|
+
const currentTab = fieldTabs[activeTab] || fieldTabs[0];
|
|
421
|
+
|
|
94
422
|
return (
|
|
95
|
-
<div
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
423
|
+
<div
|
|
424
|
+
key={field.name || `tabs-${Math.random()}`}
|
|
425
|
+
className="space-y-8"
|
|
426
|
+
>
|
|
427
|
+
<div className="flex items-center gap-1 border-b border-[var(--kyro-border)] mb-6">
|
|
428
|
+
{fieldTabs.map((tab: any, index: number) => (
|
|
429
|
+
<button
|
|
430
|
+
key={index}
|
|
431
|
+
type="button"
|
|
432
|
+
className={`px-6 py-3 text-sm font-bold transition-all border-b-2 -mb-[1px] ${
|
|
433
|
+
activeTab === index
|
|
434
|
+
? "border-[var(--kyro-text-primary)] text-[var(--kyro-text-primary)]"
|
|
435
|
+
: "border-transparent text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
436
|
+
}`}
|
|
437
|
+
onClick={() => setActiveTab(index)}
|
|
438
|
+
>
|
|
439
|
+
{tab.label}
|
|
440
|
+
</button>
|
|
441
|
+
))}
|
|
442
|
+
</div>
|
|
443
|
+
<div className="space-y-6">
|
|
444
|
+
{currentTab?.fields.map((f: Field) =>
|
|
445
|
+
renderField(f, parentData, onParentChange),
|
|
100
446
|
)}
|
|
101
|
-
</
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
maxLength={(field as any).maxLength}
|
|
117
|
-
/>
|
|
118
|
-
{field.admin?.description && !error && (
|
|
119
|
-
<p className="kyro-form-help">{field.admin.description}</p>
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
{currentTab?.label === "SEO" && (
|
|
450
|
+
<div className="mt-12 pt-8 border-t border-[var(--kyro-border)]">
|
|
451
|
+
<h4 className="text-xs font-bold text-[var(--kyro-text-secondary)] uppercase tracking-[0.2em] mb-6 opacity-50">
|
|
452
|
+
Live Google Preview
|
|
453
|
+
</h4>
|
|
454
|
+
<SeoPreview
|
|
455
|
+
title={formData.metaTitle || formData.title || "Untitled"}
|
|
456
|
+
description={
|
|
457
|
+
formData.metaDescription || "Please enter a description..."
|
|
458
|
+
}
|
|
459
|
+
slug={formData.slug || "your-slug"}
|
|
460
|
+
/>
|
|
461
|
+
</div>
|
|
120
462
|
)}
|
|
121
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
122
463
|
</div>
|
|
123
464
|
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case "text":
|
|
468
|
+
case "email":
|
|
469
|
+
const textValue = currentData[field.name!];
|
|
470
|
+
const isKeyHidden = String(textValue).startsWith("••");
|
|
124
471
|
|
|
125
|
-
case "password":
|
|
126
472
|
return (
|
|
127
473
|
<div key={field.name} className="kyro-form-field">
|
|
128
|
-
<label className="kyro-form-label">
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
474
|
+
<label className="kyro-form-label flex items-center justify-between">
|
|
475
|
+
<div className="flex items-center gap-2">
|
|
476
|
+
{field.label || field.name}
|
|
477
|
+
{field.required && (
|
|
478
|
+
<span className="kyro-form-label-required">*</span>
|
|
479
|
+
)}
|
|
480
|
+
</div>
|
|
481
|
+
{(field.admin?.autoGenerate || field.admin?.readOnly) && (
|
|
482
|
+
<button
|
|
483
|
+
type="button"
|
|
484
|
+
onClick={async (e) => {
|
|
485
|
+
e.preventDefault();
|
|
486
|
+
e.stopPropagation();
|
|
487
|
+
|
|
488
|
+
if (field.admin?.autoGenerate === "key") {
|
|
489
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
490
|
+
let suffix = "";
|
|
491
|
+
for (let i = 0; i < 32; i++) {
|
|
492
|
+
suffix +=
|
|
493
|
+
chars[Math.floor(Math.random() * chars.length)];
|
|
494
|
+
}
|
|
495
|
+
onFieldChange(`kyro_${suffix}`);
|
|
496
|
+
} else if (
|
|
497
|
+
field.admin?.readOnly &&
|
|
498
|
+
textValue &&
|
|
499
|
+
!isKeyHidden
|
|
500
|
+
) {
|
|
501
|
+
await navigator.clipboard.writeText(String(textValue));
|
|
502
|
+
// Store the actual key in a temp var and show hidden
|
|
503
|
+
const actualKey = textValue;
|
|
504
|
+
onFieldChange(actualKey + "__COPIED__");
|
|
505
|
+
setTimeout(
|
|
506
|
+
() => onFieldChange("••••••••••••••••••••••••••••••"),
|
|
507
|
+
100,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}}
|
|
511
|
+
className="p-1.5 rounded-lg text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:bg-[var(--kyro-surface-accent)] transition-all"
|
|
512
|
+
title={
|
|
513
|
+
field.admin?.autoGenerate === "key"
|
|
514
|
+
? "Generate new key"
|
|
515
|
+
: "Copy to clipboard"
|
|
516
|
+
}
|
|
517
|
+
>
|
|
518
|
+
{field.admin?.autoGenerate === "key" ? (
|
|
519
|
+
<svg
|
|
520
|
+
width="14"
|
|
521
|
+
height="14"
|
|
522
|
+
viewBox="0 0 24 24"
|
|
523
|
+
fill="none"
|
|
524
|
+
stroke="currentColor"
|
|
525
|
+
strokeWidth="2"
|
|
526
|
+
>
|
|
527
|
+
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
|
528
|
+
</svg>
|
|
529
|
+
) : (
|
|
530
|
+
<svg
|
|
531
|
+
width="14"
|
|
532
|
+
height="14"
|
|
533
|
+
viewBox="0 0 24 24"
|
|
534
|
+
fill="none"
|
|
535
|
+
stroke="currentColor"
|
|
536
|
+
strokeWidth="2"
|
|
537
|
+
>
|
|
538
|
+
<rect x="8" y="8" width="12" height="12" rx="2" />
|
|
539
|
+
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
|
|
540
|
+
</svg>
|
|
541
|
+
)}
|
|
542
|
+
</button>
|
|
132
543
|
)}
|
|
133
544
|
</label>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
545
|
+
{field.name === "slug" ? (
|
|
546
|
+
<div className="flex items-center gap-2">
|
|
547
|
+
<div className="relative flex-1">
|
|
548
|
+
<input
|
|
549
|
+
type="text"
|
|
550
|
+
className={`kyro-form-input pr-24 ${isSlugLocked ? "opacity-70 bg-[var(--kyro-bg-secondary)]" : ""}`}
|
|
551
|
+
value={value || ""}
|
|
552
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
553
|
+
disabled={isSlugLocked || disabled}
|
|
554
|
+
/>
|
|
555
|
+
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
|
556
|
+
{!isSlugLocked && (
|
|
557
|
+
<button
|
|
558
|
+
type="button"
|
|
559
|
+
onClick={() =>
|
|
560
|
+
onFieldChange(slugifyText(formData.title || ""))
|
|
561
|
+
}
|
|
562
|
+
className="p-1 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)]"
|
|
563
|
+
>
|
|
564
|
+
<svg
|
|
565
|
+
width="12"
|
|
566
|
+
height="12"
|
|
567
|
+
viewBox="0 0 24 24"
|
|
568
|
+
fill="none"
|
|
569
|
+
stroke="currentColor"
|
|
570
|
+
strokeWidth="2.5"
|
|
571
|
+
>
|
|
572
|
+
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
|
573
|
+
<path d="M21 3v5h-5" />
|
|
574
|
+
</svg>
|
|
575
|
+
</button>
|
|
576
|
+
)}
|
|
577
|
+
<button
|
|
578
|
+
type="button"
|
|
579
|
+
onClick={() => setIsSlugLocked(!isSlugLocked)}
|
|
580
|
+
className={`p-1.5 rounded ${isSlugLocked ? "text-[var(--kyro-primary)]" : "text-[var(--kyro-text-secondary)]"}`}
|
|
581
|
+
>
|
|
582
|
+
{isSlugLocked ? (
|
|
583
|
+
<svg
|
|
584
|
+
width="12"
|
|
585
|
+
height="12"
|
|
586
|
+
viewBox="0 0 24 24"
|
|
587
|
+
fill="none"
|
|
588
|
+
stroke="currentColor"
|
|
589
|
+
strokeWidth="2.5"
|
|
590
|
+
>
|
|
591
|
+
<rect
|
|
592
|
+
x="3"
|
|
593
|
+
y="11"
|
|
594
|
+
width="18"
|
|
595
|
+
height="11"
|
|
596
|
+
rx="2"
|
|
597
|
+
ry="2"
|
|
598
|
+
/>
|
|
599
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
600
|
+
</svg>
|
|
601
|
+
) : (
|
|
602
|
+
<svg
|
|
603
|
+
width="12"
|
|
604
|
+
height="12"
|
|
605
|
+
viewBox="0 0 24 24"
|
|
606
|
+
fill="none"
|
|
607
|
+
stroke="currentColor"
|
|
608
|
+
strokeWidth="2.5"
|
|
609
|
+
>
|
|
610
|
+
<rect
|
|
611
|
+
x="3"
|
|
612
|
+
y="11"
|
|
613
|
+
width="18"
|
|
614
|
+
height="11"
|
|
615
|
+
rx="2"
|
|
616
|
+
ry="2"
|
|
617
|
+
/>
|
|
618
|
+
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
|
619
|
+
</svg>
|
|
620
|
+
)}
|
|
621
|
+
</button>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
) : (
|
|
626
|
+
<input
|
|
627
|
+
type={(field as any).variant === "url" ? "url" : "text"}
|
|
628
|
+
className="kyro-form-input"
|
|
629
|
+
value={value || ""}
|
|
630
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
631
|
+
disabled={disabled}
|
|
632
|
+
/>
|
|
633
|
+
)}
|
|
634
|
+
{field.name?.toLowerCase().includes("metatitle") && (
|
|
635
|
+
<div className="flex items-center justify-between mt-1 text-[10px] font-bold uppercase tracking-wider">
|
|
636
|
+
<span
|
|
637
|
+
className={
|
|
638
|
+
(value?.length || 0) > 60
|
|
639
|
+
? "text-red-500"
|
|
640
|
+
: (value?.length || 0) >= 40
|
|
641
|
+
? "text-green-500"
|
|
642
|
+
: "text-amber-600"
|
|
643
|
+
}
|
|
644
|
+
>
|
|
645
|
+
{value?.length || 0} / 60 —{" "}
|
|
646
|
+
{(value?.length || 0) > 60
|
|
647
|
+
? "Too Long"
|
|
648
|
+
: (value?.length || 0) >= 40
|
|
649
|
+
? "Ideal"
|
|
650
|
+
: "Short"}
|
|
651
|
+
</span>
|
|
652
|
+
</div>
|
|
653
|
+
)}
|
|
142
654
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
143
655
|
</div>
|
|
144
656
|
);
|
|
@@ -146,104 +658,182 @@ export function AutoForm({
|
|
|
146
658
|
case "textarea":
|
|
147
659
|
return (
|
|
148
660
|
<div key={field.name} className="kyro-form-field">
|
|
149
|
-
<label className="kyro-form-label">
|
|
661
|
+
<label className="kyro-form-label flex items-center justify-between">
|
|
150
662
|
{field.label || field.name}
|
|
151
|
-
{field.
|
|
152
|
-
<
|
|
663
|
+
{field.admin?.autoGenerate && (
|
|
664
|
+
<button
|
|
665
|
+
type="button"
|
|
666
|
+
onClick={() =>
|
|
667
|
+
onFieldChange(
|
|
668
|
+
stripHtml(
|
|
669
|
+
formData[field.admin!.autoGenerate!] || "",
|
|
670
|
+
).slice(0, 160),
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
className="p-1 text-[var(--kyro-text-secondary)]"
|
|
674
|
+
>
|
|
675
|
+
<svg
|
|
676
|
+
width="14"
|
|
677
|
+
height="14"
|
|
678
|
+
viewBox="0 0 24 24"
|
|
679
|
+
fill="none"
|
|
680
|
+
stroke="currentColor"
|
|
681
|
+
strokeWidth="2"
|
|
682
|
+
>
|
|
683
|
+
<path d="M12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
|
684
|
+
</svg>
|
|
685
|
+
</button>
|
|
153
686
|
)}
|
|
154
687
|
</label>
|
|
155
688
|
<textarea
|
|
156
|
-
className=
|
|
689
|
+
className="kyro-form-input kyro-form-textarea"
|
|
157
690
|
value={value || ""}
|
|
158
|
-
onChange={(e) =>
|
|
691
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
159
692
|
disabled={disabled}
|
|
160
|
-
rows={
|
|
161
|
-
placeholder={`Enter ${field.label || field.name}`}
|
|
693
|
+
rows={4}
|
|
162
694
|
/>
|
|
163
|
-
{field.
|
|
164
|
-
<
|
|
695
|
+
{field.name?.toLowerCase().includes("metadescription") && (
|
|
696
|
+
<div className="mt-1 text-[10px] font-bold uppercase tracking-wider">
|
|
697
|
+
<span
|
|
698
|
+
className={
|
|
699
|
+
(value?.length || 0) > 160
|
|
700
|
+
? "text-red-500"
|
|
701
|
+
: (value?.length || 0) >= 120
|
|
702
|
+
? "text-green-500"
|
|
703
|
+
: "text-amber-600"
|
|
704
|
+
}
|
|
705
|
+
>
|
|
706
|
+
{value?.length || 0} / 160 —{" "}
|
|
707
|
+
{(value?.length || 0) > 160
|
|
708
|
+
? "Too Long"
|
|
709
|
+
: (value?.length || 0) >= 120
|
|
710
|
+
? "Ideal"
|
|
711
|
+
: "Short"}
|
|
712
|
+
</span>
|
|
713
|
+
</div>
|
|
165
714
|
)}
|
|
166
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
167
715
|
</div>
|
|
168
716
|
);
|
|
169
717
|
|
|
170
|
-
case "
|
|
718
|
+
case "richtext":
|
|
719
|
+
// TEMP: replaced with textarea to isolate hydration crash
|
|
171
720
|
return (
|
|
172
721
|
<div key={field.name} className="kyro-form-field">
|
|
173
722
|
<label className="kyro-form-label">
|
|
174
723
|
{field.label || field.name}
|
|
175
|
-
{field.required && (
|
|
176
|
-
<span className="kyro-form-label-required">*</span>
|
|
177
|
-
)}
|
|
178
724
|
</label>
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
value={
|
|
183
|
-
|
|
184
|
-
|
|
725
|
+
<textarea
|
|
726
|
+
className="kyro-form-input kyro-form-textarea"
|
|
727
|
+
rows={10}
|
|
728
|
+
value={
|
|
729
|
+
typeof value === "string"
|
|
730
|
+
? value
|
|
731
|
+
: value
|
|
732
|
+
? JSON.stringify(value, null, 2)
|
|
733
|
+
: ""
|
|
185
734
|
}
|
|
735
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
186
736
|
disabled={disabled}
|
|
187
|
-
placeholder="
|
|
188
|
-
min={(field as any).min}
|
|
189
|
-
max={(field as any).max}
|
|
190
|
-
step={(field as any).step || "any"}
|
|
737
|
+
placeholder="Enter content..."
|
|
191
738
|
/>
|
|
192
|
-
{field.admin?.description && !error && (
|
|
193
|
-
<p className="kyro-form-help">{field.admin.description}</p>
|
|
194
|
-
)}
|
|
195
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
196
739
|
</div>
|
|
197
740
|
);
|
|
198
741
|
|
|
199
|
-
case "
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
checked={value || false}
|
|
206
|
-
onChange={(e) =>
|
|
207
|
-
handleFieldChange(field.name!, e.target.checked)
|
|
208
|
-
}
|
|
209
|
-
disabled={disabled}
|
|
210
|
-
/>
|
|
211
|
-
<span className="kyro-form-checkbox-label">
|
|
742
|
+
case "group":
|
|
743
|
+
if ("fields" in field) {
|
|
744
|
+
const groupData = value || {};
|
|
745
|
+
return (
|
|
746
|
+
<div key={field.name} className="kyro-form-group">
|
|
747
|
+
<h3 className="kyro-form-group-title">
|
|
212
748
|
{field.label || field.name}
|
|
213
|
-
</
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
749
|
+
</h3>
|
|
750
|
+
<div className="kyro-form-group-fields">
|
|
751
|
+
{(field as any).fields.map((f: Field) =>
|
|
752
|
+
renderField(f, groupData, onFieldChange),
|
|
753
|
+
)}
|
|
754
|
+
</div>
|
|
755
|
+
</div>
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
return null;
|
|
220
759
|
|
|
221
|
-
case "
|
|
760
|
+
case "array":
|
|
761
|
+
if ("fields" in field) {
|
|
762
|
+
const items = Array.isArray(value) ? value : [];
|
|
763
|
+
return (
|
|
764
|
+
<div key={field.name} className="kyro-form-field">
|
|
765
|
+
<label className="kyro-form-label">
|
|
766
|
+
{field.label || field.name}
|
|
767
|
+
</label>
|
|
768
|
+
<div className="kyro-form-array">
|
|
769
|
+
{items.map((item: any, index: number) => (
|
|
770
|
+
<div key={index} className="kyro-form-array-item">
|
|
771
|
+
<div className="flex justify-between mb-2">
|
|
772
|
+
<span className="text-xs font-bold opacity-50">
|
|
773
|
+
Item {index + 1}
|
|
774
|
+
</span>
|
|
775
|
+
<button
|
|
776
|
+
type="button"
|
|
777
|
+
className="text-red-500"
|
|
778
|
+
onClick={() =>
|
|
779
|
+
onFieldChange(items.filter((_, i) => i !== index))
|
|
780
|
+
}
|
|
781
|
+
>
|
|
782
|
+
Remove
|
|
783
|
+
</button>
|
|
784
|
+
</div>
|
|
785
|
+
{(field as any).fields.map((f: Field) =>
|
|
786
|
+
renderField(f, item, (newItem) => {
|
|
787
|
+
const newItems = [...items];
|
|
788
|
+
newItems[index] = newItem;
|
|
789
|
+
onFieldChange(newItems);
|
|
790
|
+
}),
|
|
791
|
+
)}
|
|
792
|
+
</div>
|
|
793
|
+
))}
|
|
794
|
+
<button
|
|
795
|
+
type="button"
|
|
796
|
+
className="kyro-btn kyro-btn-secondary kyro-btn-sm"
|
|
797
|
+
onClick={() => onFieldChange([...items, {}])}
|
|
798
|
+
>
|
|
799
|
+
Add Item
|
|
800
|
+
</button>
|
|
801
|
+
</div>
|
|
802
|
+
</div>
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
return null;
|
|
806
|
+
|
|
807
|
+
case "blocks":
|
|
222
808
|
return (
|
|
223
809
|
<div key={field.name} className="kyro-form-field">
|
|
224
810
|
<label className="kyro-form-label">
|
|
225
811
|
{field.label || field.name}
|
|
226
|
-
{field.required && (
|
|
227
|
-
<span className="kyro-form-label-required">*</span>
|
|
228
|
-
)}
|
|
229
812
|
</label>
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
value={
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
813
|
+
<textarea
|
|
814
|
+
className="kyro-form-input kyro-form-textarea"
|
|
815
|
+
rows={10}
|
|
816
|
+
value={
|
|
817
|
+
typeof value === "string"
|
|
818
|
+
? value
|
|
819
|
+
: value
|
|
820
|
+
? JSON.stringify(value, null, 2)
|
|
821
|
+
: ""
|
|
239
822
|
}
|
|
823
|
+
onChange={(e) => {
|
|
824
|
+
try {
|
|
825
|
+
onFieldChange(JSON.parse(e.target.value));
|
|
826
|
+
} catch {
|
|
827
|
+
onFieldChange(e.target.value);
|
|
828
|
+
}
|
|
829
|
+
}}
|
|
240
830
|
disabled={disabled}
|
|
831
|
+
placeholder="Blocks data (JSON)..."
|
|
241
832
|
/>
|
|
242
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
243
833
|
</div>
|
|
244
834
|
);
|
|
245
835
|
|
|
246
|
-
case "
|
|
836
|
+
case "number":
|
|
247
837
|
return (
|
|
248
838
|
<div key={field.name} className="kyro-form-field">
|
|
249
839
|
<label className="kyro-form-label">
|
|
@@ -252,57 +842,31 @@ export function AutoForm({
|
|
|
252
842
|
<span className="kyro-form-label-required">*</span>
|
|
253
843
|
)}
|
|
254
844
|
</label>
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
value={value
|
|
258
|
-
onChange={(
|
|
845
|
+
<NumberField
|
|
846
|
+
field={field as any}
|
|
847
|
+
value={value}
|
|
848
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
259
849
|
disabled={disabled}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
{(field as any).options?.map((opt: any) => (
|
|
263
|
-
<option key={opt.value || opt} value={opt.value || opt}>
|
|
264
|
-
{opt.label || opt}
|
|
265
|
-
</option>
|
|
266
|
-
))}
|
|
267
|
-
</select>
|
|
268
|
-
{field.admin?.description && !error && (
|
|
269
|
-
<p className="kyro-form-help">{field.admin.description}</p>
|
|
270
|
-
)}
|
|
271
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
850
|
+
error={error}
|
|
851
|
+
/>
|
|
272
852
|
</div>
|
|
273
853
|
);
|
|
274
854
|
|
|
275
|
-
case "
|
|
855
|
+
case "checkbox":
|
|
276
856
|
return (
|
|
277
857
|
<div key={field.name} className="kyro-form-field">
|
|
278
|
-
<
|
|
279
|
-
{field
|
|
280
|
-
{
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
{(field as any).options?.map((opt: any) => (
|
|
286
|
-
<label key={opt.value || opt} className="kyro-form-radio">
|
|
287
|
-
<input
|
|
288
|
-
type="radio"
|
|
289
|
-
name={field.name}
|
|
290
|
-
value={opt.value || opt}
|
|
291
|
-
checked={value === (opt.value || opt)}
|
|
292
|
-
onChange={(e) =>
|
|
293
|
-
handleFieldChange(field.name!, e.target.value)
|
|
294
|
-
}
|
|
295
|
-
disabled={disabled}
|
|
296
|
-
/>
|
|
297
|
-
<span>{opt.label || opt}</span>
|
|
298
|
-
</label>
|
|
299
|
-
))}
|
|
300
|
-
</div>
|
|
858
|
+
<CheckboxField
|
|
859
|
+
field={field as any}
|
|
860
|
+
value={value}
|
|
861
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
862
|
+
disabled={disabled}
|
|
863
|
+
error={error}
|
|
864
|
+
/>
|
|
301
865
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
302
866
|
</div>
|
|
303
867
|
);
|
|
304
868
|
|
|
305
|
-
case "
|
|
869
|
+
case "select":
|
|
306
870
|
return (
|
|
307
871
|
<div key={field.name} className="kyro-form-field">
|
|
308
872
|
<label className="kyro-form-label">
|
|
@@ -311,23 +875,18 @@ export function AutoForm({
|
|
|
311
875
|
<span className="kyro-form-label-required">*</span>
|
|
312
876
|
)}
|
|
313
877
|
</label>
|
|
314
|
-
<
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
/>
|
|
322
|
-
<span className="kyro-form-color-value">
|
|
323
|
-
{value || "#000000"}
|
|
324
|
-
</span>
|
|
325
|
-
</div>
|
|
878
|
+
<SelectField
|
|
879
|
+
field={field as any}
|
|
880
|
+
value={value}
|
|
881
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
882
|
+
disabled={disabled}
|
|
883
|
+
error={error}
|
|
884
|
+
/>
|
|
326
885
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
327
886
|
</div>
|
|
328
887
|
);
|
|
329
888
|
|
|
330
|
-
case "
|
|
889
|
+
case "date":
|
|
331
890
|
return (
|
|
332
891
|
<div key={field.name} className="kyro-form-field">
|
|
333
892
|
<label className="kyro-form-label">
|
|
@@ -336,56 +895,43 @@ export function AutoForm({
|
|
|
336
895
|
<span className="kyro-form-label-required">*</span>
|
|
337
896
|
)}
|
|
338
897
|
</label>
|
|
339
|
-
<
|
|
340
|
-
|
|
341
|
-
value={
|
|
342
|
-
|
|
343
|
-
? value
|
|
344
|
-
: JSON.stringify(value || {}, null, 2)
|
|
345
|
-
}
|
|
346
|
-
onChange={(e) => {
|
|
347
|
-
try {
|
|
348
|
-
handleFieldChange(field.name!, JSON.parse(e.target.value));
|
|
349
|
-
} catch {
|
|
350
|
-
handleFieldChange(field.name!, e.target.value);
|
|
351
|
-
}
|
|
352
|
-
}}
|
|
898
|
+
<DateField
|
|
899
|
+
field={field as any}
|
|
900
|
+
value={value}
|
|
901
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
353
902
|
disabled={disabled}
|
|
354
|
-
|
|
355
|
-
placeholder='{"key": "value"}'
|
|
903
|
+
error={error}
|
|
356
904
|
/>
|
|
357
|
-
{field.admin?.description && !error && (
|
|
358
|
-
<p className="kyro-form-help">{field.admin.description}</p>
|
|
359
|
-
)}
|
|
360
905
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
361
906
|
</div>
|
|
362
907
|
);
|
|
363
908
|
|
|
364
|
-
case "
|
|
909
|
+
case "password":
|
|
365
910
|
return (
|
|
366
911
|
<div key={field.name} className="kyro-form-field">
|
|
367
|
-
<label className="kyro-form-label">
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
912
|
+
<label className="kyro-form-label flex items-center justify-between">
|
|
913
|
+
<div className="flex items-center gap-2">
|
|
914
|
+
{field.label || field.name}
|
|
915
|
+
{field.required && (
|
|
916
|
+
<span className="kyro-form-label-required">*</span>
|
|
917
|
+
)}
|
|
918
|
+
</div>
|
|
372
919
|
</label>
|
|
373
|
-
<
|
|
374
|
-
|
|
920
|
+
<input
|
|
921
|
+
type="password"
|
|
922
|
+
className="kyro-form-input"
|
|
375
923
|
value={value || ""}
|
|
376
|
-
onChange={(e) =>
|
|
924
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
377
925
|
disabled={disabled}
|
|
378
|
-
|
|
379
|
-
|
|
926
|
+
placeholder={
|
|
927
|
+
field.admin?.placeholder || `Enter ${field.label || field.name}`
|
|
928
|
+
}
|
|
380
929
|
/>
|
|
381
|
-
{field.admin?.description && !error && (
|
|
382
|
-
<p className="kyro-form-help">{field.admin.description}</p>
|
|
383
|
-
)}
|
|
384
930
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
385
931
|
</div>
|
|
386
932
|
);
|
|
387
933
|
|
|
388
|
-
case "
|
|
934
|
+
case "radio":
|
|
389
935
|
return (
|
|
390
936
|
<div key={field.name} className="kyro-form-field">
|
|
391
937
|
<label className="kyro-form-label">
|
|
@@ -394,281 +940,140 @@ export function AutoForm({
|
|
|
394
940
|
<span className="kyro-form-label-required">*</span>
|
|
395
941
|
)}
|
|
396
942
|
</label>
|
|
397
|
-
<
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
943
|
+
<div className="kyro-form-radio-group">
|
|
944
|
+
{((field as any).options || []).map((opt: any) => (
|
|
945
|
+
<label key={opt.value} className="kyro-form-radio-label">
|
|
946
|
+
<input
|
|
947
|
+
type="radio"
|
|
948
|
+
name={field.name}
|
|
949
|
+
value={opt.value}
|
|
950
|
+
checked={value === opt.value}
|
|
951
|
+
onChange={() => onFieldChange(opt.value)}
|
|
952
|
+
disabled={disabled}
|
|
953
|
+
className="kyro-form-radio"
|
|
954
|
+
/>
|
|
955
|
+
<span>{opt.label || opt.value}</span>
|
|
956
|
+
</label>
|
|
957
|
+
))}
|
|
958
|
+
</div>
|
|
408
959
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
409
960
|
</div>
|
|
410
961
|
);
|
|
411
962
|
|
|
412
|
-
case "
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
{
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const subFieldValue = item[fieldKey];
|
|
481
|
-
const handleSubFieldChange = (newValue: any) => {
|
|
482
|
-
const newItem = { ...item, [fieldKey]: newValue };
|
|
483
|
-
const newItems = [...items];
|
|
484
|
-
newItems[index] = newItem;
|
|
485
|
-
handleFieldChange(field.name!, newItems);
|
|
486
|
-
};
|
|
487
|
-
return (
|
|
488
|
-
<div key={fieldKey} className="kyro-form-field">
|
|
489
|
-
<label className="kyro-form-label">
|
|
490
|
-
{f.label || f.name}
|
|
491
|
-
</label>
|
|
492
|
-
{renderSubField(
|
|
493
|
-
f,
|
|
494
|
-
subFieldValue,
|
|
495
|
-
handleSubFieldChange,
|
|
496
|
-
disabled,
|
|
497
|
-
)}
|
|
498
|
-
</div>
|
|
499
|
-
);
|
|
500
|
-
})}
|
|
501
|
-
</div>
|
|
502
|
-
</div>
|
|
503
|
-
))
|
|
504
|
-
)}
|
|
505
|
-
<button
|
|
506
|
-
type="button"
|
|
507
|
-
className="kyro-btn kyro-btn-secondary kyro-btn-sm"
|
|
508
|
-
onClick={() => {
|
|
509
|
-
const newItem: Record<string, any> = {};
|
|
510
|
-
(field as any).fields.forEach((f: Field) => {
|
|
511
|
-
if (f.defaultValue !== undefined) {
|
|
512
|
-
newItem[f.name!] = f.defaultValue;
|
|
513
|
-
}
|
|
963
|
+
case "color":
|
|
964
|
+
return (
|
|
965
|
+
<div key={field.name} className="kyro-form-field">
|
|
966
|
+
<label className="kyro-form-label flex items-center gap-2">
|
|
967
|
+
{field.label || field.name}
|
|
968
|
+
{field.required && (
|
|
969
|
+
<span className="kyro-form-label-required">*</span>
|
|
970
|
+
)}
|
|
971
|
+
{value && (
|
|
972
|
+
<span
|
|
973
|
+
className="w-5 h-5 rounded border border-[var(--kyro-border)] shrink-0"
|
|
974
|
+
style={{ backgroundColor: value }}
|
|
975
|
+
/>
|
|
976
|
+
)}
|
|
977
|
+
</label>
|
|
978
|
+
<div className="flex items-center gap-3">
|
|
979
|
+
<input
|
|
980
|
+
type="color"
|
|
981
|
+
value={value || "#000000"}
|
|
982
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
983
|
+
disabled={disabled}
|
|
984
|
+
className="kyro-form-input h-10 w-14 p-1 cursor-pointer"
|
|
985
|
+
/>
|
|
986
|
+
<input
|
|
987
|
+
type="text"
|
|
988
|
+
className="kyro-form-input font-mono uppercase"
|
|
989
|
+
value={value || ""}
|
|
990
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
991
|
+
disabled={disabled}
|
|
992
|
+
placeholder="#000000"
|
|
993
|
+
/>
|
|
994
|
+
</div>
|
|
995
|
+
{error && <p className="kyro-form-error">{error}</p>}
|
|
996
|
+
</div>
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
case "markdown":
|
|
1000
|
+
return (
|
|
1001
|
+
<MarkdownField
|
|
1002
|
+
key={field.name}
|
|
1003
|
+
field={field as any}
|
|
1004
|
+
value={value || ""}
|
|
1005
|
+
onChange={(val) => onFieldChange(val)}
|
|
1006
|
+
disabled={disabled}
|
|
1007
|
+
/>
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
case "button": {
|
|
1011
|
+
const isLoading = loadingFields[field.name!];
|
|
1012
|
+
return (
|
|
1013
|
+
<div key={field.name} className="kyro-form-field">
|
|
1014
|
+
<button
|
|
1015
|
+
type="button"
|
|
1016
|
+
disabled={isLoading || disabled}
|
|
1017
|
+
onClick={async () => {
|
|
1018
|
+
const action = field.admin?.action || (field as any).action;
|
|
1019
|
+
const method =
|
|
1020
|
+
field.admin?.method || (field as any).method || "POST";
|
|
1021
|
+
if (action) {
|
|
1022
|
+
setLoadingFields((prev) => ({
|
|
1023
|
+
...prev,
|
|
1024
|
+
[field.name!]: true,
|
|
1025
|
+
}));
|
|
1026
|
+
try {
|
|
1027
|
+
const response = await fetch(action, {
|
|
1028
|
+
method,
|
|
1029
|
+
headers: { "Content-Type": "application/json" },
|
|
1030
|
+
body: JSON.stringify(formData),
|
|
514
1031
|
});
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1032
|
+
const result = await response.json();
|
|
1033
|
+
if (response.ok) {
|
|
1034
|
+
// handle result
|
|
1035
|
+
} else {
|
|
1036
|
+
// handle error
|
|
1037
|
+
}
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
console.error("Error executing action:", err);
|
|
1040
|
+
} finally {
|
|
1041
|
+
setLoadingFields((prev) => ({
|
|
1042
|
+
...prev,
|
|
1043
|
+
[field.name!]: false,
|
|
1044
|
+
}));
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}}
|
|
1048
|
+
className={`kyro-btn kyro-btn-md kyro-btn-secondary transition-all active:scale-95 whitespace-nowrap flex items-center gap-2 ${isLoading ? "opacity-70 cursor-not-allowed" : ""}`}
|
|
1049
|
+
>
|
|
1050
|
+
{isLoading && (
|
|
1051
|
+
<svg
|
|
1052
|
+
className="animate-spin h-3 w-3 text-white"
|
|
1053
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1054
|
+
fill="none"
|
|
1055
|
+
viewBox="0 0 24 24"
|
|
518
1056
|
>
|
|
519
|
-
<
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
1057
|
+
<circle
|
|
1058
|
+
className="opacity-25"
|
|
1059
|
+
cx="12"
|
|
1060
|
+
cy="12"
|
|
1061
|
+
r="10"
|
|
524
1062
|
stroke="currentColor"
|
|
525
|
-
strokeWidth="
|
|
526
|
-
>
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const blocks = Array.isArray(value) ? value : [];
|
|
540
|
-
const labels = (field as any).labels || {
|
|
541
|
-
singular: "Block",
|
|
542
|
-
plural: "Blocks",
|
|
543
|
-
};
|
|
544
|
-
return (
|
|
545
|
-
<div key={field.name} className="kyro-form-field">
|
|
546
|
-
<label className="kyro-form-label">
|
|
547
|
-
{field.label || field.name}
|
|
548
|
-
</label>
|
|
549
|
-
<div className="kyro-form-blocks">
|
|
550
|
-
{blocks.length === 0 ? (
|
|
551
|
-
<p className="kyro-form-blocks-empty">
|
|
552
|
-
No {labels.plural.toLowerCase()} yet
|
|
553
|
-
</p>
|
|
554
|
-
) : (
|
|
555
|
-
blocks.map((block: any, index: number) => (
|
|
556
|
-
<div key={index} className="kyro-form-block-item">
|
|
557
|
-
<div className="kyro-form-block-item-header">
|
|
558
|
-
<span className="kyro-form-block-item-type">
|
|
559
|
-
{(field as any).blocks.find(
|
|
560
|
-
(b: Block) => b.slug === block.blockType,
|
|
561
|
-
)?.label || block.blockType}
|
|
562
|
-
</span>
|
|
563
|
-
<div className="kyro-form-block-item-actions">
|
|
564
|
-
<button
|
|
565
|
-
type="button"
|
|
566
|
-
className="kyro-form-block-item-move"
|
|
567
|
-
onClick={() => {
|
|
568
|
-
if (index > 0) {
|
|
569
|
-
const newBlocks = [...blocks];
|
|
570
|
-
[newBlocks[index - 1], newBlocks[index]] = [
|
|
571
|
-
newBlocks[index],
|
|
572
|
-
newBlocks[index - 1],
|
|
573
|
-
];
|
|
574
|
-
handleFieldChange(field.name!, newBlocks);
|
|
575
|
-
}
|
|
576
|
-
}}
|
|
577
|
-
disabled={disabled || index === 0}
|
|
578
|
-
>
|
|
579
|
-
<svg
|
|
580
|
-
width="14"
|
|
581
|
-
height="14"
|
|
582
|
-
viewBox="0 0 24 24"
|
|
583
|
-
fill="none"
|
|
584
|
-
stroke="currentColor"
|
|
585
|
-
strokeWidth="2"
|
|
586
|
-
>
|
|
587
|
-
<path d="M18 15l-6-6-6 6" />
|
|
588
|
-
</svg>
|
|
589
|
-
</button>
|
|
590
|
-
<button
|
|
591
|
-
type="button"
|
|
592
|
-
className="kyro-form-block-item-remove"
|
|
593
|
-
onClick={() => {
|
|
594
|
-
const newBlocks = blocks.filter(
|
|
595
|
-
(_: any, i: number) => i !== index,
|
|
596
|
-
);
|
|
597
|
-
handleFieldChange((field as any).name, newBlocks);
|
|
598
|
-
}}
|
|
599
|
-
disabled={disabled}
|
|
600
|
-
>
|
|
601
|
-
<svg
|
|
602
|
-
width="14"
|
|
603
|
-
height="14"
|
|
604
|
-
viewBox="0 0 24 24"
|
|
605
|
-
fill="none"
|
|
606
|
-
stroke="currentColor"
|
|
607
|
-
strokeWidth="2"
|
|
608
|
-
>
|
|
609
|
-
<path d="M18 6L6 18M6 6l12 12" />
|
|
610
|
-
</svg>
|
|
611
|
-
</button>
|
|
612
|
-
</div>
|
|
613
|
-
</div>
|
|
614
|
-
<div className="kyro-form-block-item-fields">
|
|
615
|
-
{(field as any).blocks
|
|
616
|
-
.find((b: Block) => b.slug === block.blockType)
|
|
617
|
-
?.fields?.map((f: Field) => {
|
|
618
|
-
if (!f.name) return null;
|
|
619
|
-
const fieldKey = f.name;
|
|
620
|
-
const blockData = block;
|
|
621
|
-
const handleBlockFieldChange = (newValue: any) => {
|
|
622
|
-
const newBlock = {
|
|
623
|
-
...blockData,
|
|
624
|
-
[fieldKey]: newValue,
|
|
625
|
-
};
|
|
626
|
-
const newBlocks = [...blocks];
|
|
627
|
-
newBlocks[index] = newBlock;
|
|
628
|
-
handleFieldChange(field.name!, newBlocks);
|
|
629
|
-
};
|
|
630
|
-
return (
|
|
631
|
-
<div key={fieldKey} className="kyro-form-field">
|
|
632
|
-
<label className="kyro-form-label">
|
|
633
|
-
{f.label || f.name}
|
|
634
|
-
</label>
|
|
635
|
-
{renderSubField(
|
|
636
|
-
f,
|
|
637
|
-
block[fieldKey],
|
|
638
|
-
handleBlockFieldChange,
|
|
639
|
-
disabled,
|
|
640
|
-
)}
|
|
641
|
-
</div>
|
|
642
|
-
);
|
|
643
|
-
})}
|
|
644
|
-
</div>
|
|
645
|
-
</div>
|
|
646
|
-
))
|
|
647
|
-
)}
|
|
648
|
-
<div className="kyro-form-blocks-add">
|
|
649
|
-
<span className="kyro-form-blocks-add-label">Add block:</span>
|
|
650
|
-
<div className="kyro-form-blocks-add-buttons">
|
|
651
|
-
{(field as any).blocks.map((block: Block) => (
|
|
652
|
-
<button
|
|
653
|
-
key={block.slug}
|
|
654
|
-
type="button"
|
|
655
|
-
className="kyro-btn kyro-btn-secondary kyro-btn-sm"
|
|
656
|
-
onClick={() => {
|
|
657
|
-
const newBlock = { blockType: block.slug };
|
|
658
|
-
handleFieldChange(field.name!, [...blocks, newBlock]);
|
|
659
|
-
}}
|
|
660
|
-
disabled={disabled}
|
|
661
|
-
>
|
|
662
|
-
{block.label}
|
|
663
|
-
</button>
|
|
664
|
-
))}
|
|
665
|
-
</div>
|
|
666
|
-
</div>
|
|
667
|
-
</div>
|
|
668
|
-
</div>
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
return null;
|
|
1063
|
+
strokeWidth="4"
|
|
1064
|
+
></circle>
|
|
1065
|
+
<path
|
|
1066
|
+
className="opacity-75"
|
|
1067
|
+
fill="currentColor"
|
|
1068
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
1069
|
+
></path>
|
|
1070
|
+
</svg>
|
|
1071
|
+
)}
|
|
1072
|
+
{isLoading ? "Processing..." : field.label || "Click"}
|
|
1073
|
+
</button>
|
|
1074
|
+
</div>
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
672
1077
|
|
|
673
1078
|
case "relationship":
|
|
674
1079
|
return (
|
|
@@ -676,143 +1081,435 @@ export function AutoForm({
|
|
|
676
1081
|
key={field.name}
|
|
677
1082
|
field={field as any}
|
|
678
1083
|
value={value}
|
|
679
|
-
onChange={(newValue) =>
|
|
1084
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
680
1085
|
disabled={disabled}
|
|
681
1086
|
error={error}
|
|
682
1087
|
/>
|
|
683
1088
|
);
|
|
684
1089
|
|
|
685
1090
|
case "upload":
|
|
1091
|
+
case "image":
|
|
686
1092
|
return (
|
|
687
|
-
<
|
|
1093
|
+
<ImageField
|
|
688
1094
|
key={field.name}
|
|
689
1095
|
field={field as any}
|
|
690
1096
|
value={value}
|
|
691
|
-
onChange={(newValue) =>
|
|
1097
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
692
1098
|
disabled={disabled}
|
|
693
|
-
error={error}
|
|
694
1099
|
/>
|
|
695
1100
|
);
|
|
696
1101
|
|
|
697
|
-
|
|
698
|
-
return
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1102
|
+
default:
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
const renderHeader = () => {
|
|
1108
|
+
const docTitle = formData.title || formData.name || "Untitled";
|
|
1109
|
+
const status = formData.status || "draft";
|
|
1110
|
+
const lastModified = formData.updatedAt
|
|
1111
|
+
? new Date(formData.updatedAt).toLocaleString()
|
|
1112
|
+
: "Just now";
|
|
1113
|
+
const createdAt = formData.createdAt
|
|
1114
|
+
? new Date(formData.createdAt).toLocaleString()
|
|
1115
|
+
: "Just now";
|
|
1116
|
+
|
|
1117
|
+
return (
|
|
1118
|
+
<header className="surface-tile px-8 py-6 flex items-center justify-between sticky top-0 z-50 border-b border-[var(--kyro-border)] mb-8 bg-[var(--kyro-surface)] backdrop-blur-md">
|
|
1119
|
+
<div className="flex flex-col gap-1">
|
|
1120
|
+
<div className="flex items-center gap-4">
|
|
1121
|
+
<a
|
|
1122
|
+
href={`/${collectionSlug}`}
|
|
1123
|
+
className="p-2 border border-[var(--kyro-border)] rounded-xl hover:bg-[var(--kyro-bg-secondary)] transition-colors"
|
|
1124
|
+
>
|
|
1125
|
+
<svg
|
|
1126
|
+
className="w-4 h-4"
|
|
1127
|
+
fill="none"
|
|
1128
|
+
stroke="currentColor"
|
|
1129
|
+
viewBox="0 0 24 24"
|
|
1130
|
+
>
|
|
1131
|
+
<path
|
|
1132
|
+
strokeLinecap="round"
|
|
1133
|
+
strokeLinejoin="round"
|
|
1134
|
+
strokeWidth="2.5"
|
|
1135
|
+
d="M15 19l-7-7 7-7"
|
|
1136
|
+
/>
|
|
1137
|
+
</svg>
|
|
1138
|
+
</a>
|
|
1139
|
+
<h1 className="text-xl font-bold tracking-tighter">{docTitle}</h1>
|
|
1140
|
+
</div>
|
|
1141
|
+
<div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
|
|
1142
|
+
<span className="flex items-center gap-1.5 capitalize">
|
|
1143
|
+
<span
|
|
1144
|
+
className={`h-1.5 w-1.5 rounded-full ${status === "published" ? "bg-green-500" : "bg-amber-500"}`}
|
|
1145
|
+
/>
|
|
1146
|
+
{status}
|
|
1147
|
+
</span>
|
|
1148
|
+
{hasUnsavedChanges && (
|
|
1149
|
+
<>
|
|
1150
|
+
<span className="opacity-30">—</span>
|
|
1151
|
+
<button
|
|
1152
|
+
type="button"
|
|
1153
|
+
onClick={() => setFormData(initialData)}
|
|
1154
|
+
className="text-[var(--kyro-primary)] hover:underline"
|
|
1155
|
+
>
|
|
1156
|
+
Revert to published
|
|
1157
|
+
</button>
|
|
1158
|
+
</>
|
|
716
1159
|
)}
|
|
717
|
-
|
|
1160
|
+
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
1161
|
+
Modified {lastModified}
|
|
1162
|
+
</span>
|
|
1163
|
+
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
1164
|
+
Created {createdAt}
|
|
1165
|
+
</span>
|
|
718
1166
|
</div>
|
|
719
|
-
|
|
1167
|
+
</div>
|
|
720
1168
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1169
|
+
<div className="flex items-center gap-6">
|
|
1170
|
+
<div className="flex items-center gap-1 bg-[var(--kyro-bg-secondary)] p-1 rounded-xl border border-[var(--kyro-border)]">
|
|
1171
|
+
{["edit", "version", "api"].map((v) => (
|
|
1172
|
+
<button
|
|
1173
|
+
key={v}
|
|
1174
|
+
type="button"
|
|
1175
|
+
onClick={() => setView(v as any)}
|
|
1176
|
+
className={`px-5 py-2 text-xs font-black rounded-lg transition-all ${view === v ? "bg-[var(--kyro-surface)] shadow-sm border border-[var(--kyro-border)] text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
|
|
1177
|
+
>
|
|
1178
|
+
{v.toUpperCase()}
|
|
1179
|
+
</button>
|
|
1180
|
+
))}
|
|
728
1181
|
</div>
|
|
729
|
-
|
|
730
|
-
|
|
1182
|
+
|
|
1183
|
+
<div className="h-8 w-px bg-[var(--kyro-border)] mx-2" />
|
|
1184
|
+
|
|
1185
|
+
<div className="flex items-center gap-3">
|
|
1186
|
+
<button
|
|
1187
|
+
type="button"
|
|
1188
|
+
onClick={() => setShowPreview(!showPreview)}
|
|
1189
|
+
className={`p-2.5 rounded-xl transition-all flex items-center gap-2 ${showPreview ? "bg-[var(--kyro-primary)] text-white shadow-lg" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
|
|
1190
|
+
title="Live Preview"
|
|
1191
|
+
>
|
|
1192
|
+
<svg
|
|
1193
|
+
width="20"
|
|
1194
|
+
height="20"
|
|
1195
|
+
viewBox="0 0 24 24"
|
|
1196
|
+
fill="none"
|
|
1197
|
+
stroke="currentColor"
|
|
1198
|
+
strokeWidth="2"
|
|
1199
|
+
>
|
|
1200
|
+
<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" />
|
|
1201
|
+
</svg>
|
|
1202
|
+
{showPreview && (
|
|
1203
|
+
<span className="text-[10px] font-black uppercase tracking-widest pr-1">
|
|
1204
|
+
Active
|
|
1205
|
+
</span>
|
|
1206
|
+
)}
|
|
1207
|
+
</button>
|
|
1208
|
+
<button
|
|
1209
|
+
type="button"
|
|
1210
|
+
className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
|
|
1211
|
+
title="Desktop View"
|
|
1212
|
+
>
|
|
1213
|
+
<svg
|
|
1214
|
+
width="20"
|
|
1215
|
+
height="20"
|
|
1216
|
+
viewBox="0 0 24 24"
|
|
1217
|
+
fill="none"
|
|
1218
|
+
stroke="currentColor"
|
|
1219
|
+
strokeWidth="2"
|
|
1220
|
+
>
|
|
1221
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
1222
|
+
<line x1="15" y1="3" x2="15" y2="21" />
|
|
1223
|
+
</svg>
|
|
1224
|
+
</button>
|
|
1225
|
+
|
|
1226
|
+
<button
|
|
1227
|
+
id="btn-save"
|
|
1228
|
+
type="button"
|
|
1229
|
+
onClick={() =>
|
|
1230
|
+
(document.getElementById("btn-save") as any)?.click()
|
|
1231
|
+
}
|
|
1232
|
+
className="kyro-btn kyro-btn-primary px-6 py-2.5 text-xs rounded-xl shadow-lg transition-all"
|
|
1233
|
+
>
|
|
1234
|
+
Publish Changes
|
|
1235
|
+
</button>
|
|
1236
|
+
|
|
1237
|
+
<button
|
|
1238
|
+
type="button"
|
|
1239
|
+
className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
|
|
1240
|
+
>
|
|
1241
|
+
<svg
|
|
1242
|
+
width="20"
|
|
1243
|
+
height="20"
|
|
1244
|
+
viewBox="0 0 24 24"
|
|
1245
|
+
fill="none"
|
|
1246
|
+
stroke="currentColor"
|
|
1247
|
+
strokeWidth="3"
|
|
1248
|
+
>
|
|
1249
|
+
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
|
1250
|
+
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
|
|
1251
|
+
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
|
|
1252
|
+
</svg>
|
|
1253
|
+
</button>
|
|
1254
|
+
</div>
|
|
1255
|
+
</div>
|
|
1256
|
+
</header>
|
|
1257
|
+
);
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
const renderEditView = () => {
|
|
1261
|
+
// Single layout: no split grid, no sidebar column — just a clean field list
|
|
1262
|
+
if (layout === "single") {
|
|
1263
|
+
return (
|
|
1264
|
+
<div className="w-full space-y-8">
|
|
1265
|
+
<div className="surface-tile p-8 space-y-8">
|
|
1266
|
+
{config.fields.map((f) => renderField(f))}
|
|
1267
|
+
</div>
|
|
1268
|
+
</div>
|
|
1269
|
+
);
|
|
731
1270
|
}
|
|
1271
|
+
|
|
1272
|
+
// Default split layout
|
|
1273
|
+
return (
|
|
1274
|
+
<div
|
|
1275
|
+
className={`w-full mx-auto grid gap-8 pb-32 transition-all duration-700 ${showPreview ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1 lg:grid-cols-[1fr_380px]"}`}
|
|
1276
|
+
>
|
|
1277
|
+
<div className="space-y-8 animate-in fade-in slide-in-from-left-4 duration-500">
|
|
1278
|
+
{config.tabs ? (
|
|
1279
|
+
renderField({ type: "tabs", tabs: config.tabs } as any)
|
|
1280
|
+
) : (
|
|
1281
|
+
<div className="surface-tile p-8 space-y-8">
|
|
1282
|
+
{config.fields
|
|
1283
|
+
.filter(
|
|
1284
|
+
(f) => !f.admin?.position || f.admin.position === "main",
|
|
1285
|
+
)
|
|
1286
|
+
.map((f) => renderField(f))}
|
|
1287
|
+
</div>
|
|
1288
|
+
)}
|
|
1289
|
+
</div>
|
|
1290
|
+
|
|
1291
|
+
{showPreview ? (
|
|
1292
|
+
<div className="sticky top-36 h-[calc(100vh-280px)] animate-in fade-in slide-in-from-right-10 duration-700">
|
|
1293
|
+
<div className="w-full h-full rounded-3xl border border-[var(--kyro-border)] bg-[var(--kyro-bg-secondary)] shadow-2xl overflow-hidden relative group">
|
|
1294
|
+
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
|
|
1295
|
+
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
|
1296
|
+
<span className="text-[10px] font-black uppercase tracking-widest text-white/60">
|
|
1297
|
+
Live Preview Mode
|
|
1298
|
+
</span>
|
|
1299
|
+
</div>
|
|
1300
|
+
<iframe
|
|
1301
|
+
src={`/${collectionSlug}/${formData.slug || formData.id}?preview=true`}
|
|
1302
|
+
className="w-full h-full border-none"
|
|
1303
|
+
title="Live Preview"
|
|
1304
|
+
/>
|
|
1305
|
+
<div className="absolute inset-0 bg-transparent pointer-events-none border-[12px] border-[var(--kyro-surface)] rounded-3xl" />
|
|
1306
|
+
</div>
|
|
1307
|
+
</div>
|
|
1308
|
+
) : (
|
|
1309
|
+
<div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
|
|
1310
|
+
{config.fields.some((f) => f.admin?.position === "sidebar") && (
|
|
1311
|
+
<div className="surface-tile p-6 space-y-6">
|
|
1312
|
+
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
|
1313
|
+
Sidebar Settings
|
|
1314
|
+
</h3>
|
|
1315
|
+
{config.fields
|
|
1316
|
+
.filter((f) => f.admin?.position === "sidebar")
|
|
1317
|
+
.map((f) => renderField(f))}
|
|
1318
|
+
</div>
|
|
1319
|
+
)}
|
|
1320
|
+
</div>
|
|
1321
|
+
)}
|
|
1322
|
+
</div>
|
|
1323
|
+
);
|
|
732
1324
|
};
|
|
733
1325
|
|
|
734
|
-
|
|
735
|
-
<div className="
|
|
736
|
-
|
|
1326
|
+
const renderVersionView = () => (
|
|
1327
|
+
<div className="w-full space-y-6 animate-in fade-in slide-in-from-bottom-4 px-8 pb-12">
|
|
1328
|
+
<div className="surface-tile p-8">
|
|
1329
|
+
<h2 className="text-2xl font-black mb-2">Version History</h2>
|
|
1330
|
+
<p className="text-[11px] text-[var(--kyro-text-secondary)] opacity-50 mb-8 uppercase tracking-widest font-bold">
|
|
1331
|
+
Snapshots are created automatically every time you save changes.
|
|
1332
|
+
</p>
|
|
1333
|
+
|
|
1334
|
+
{loadingVersions ? (
|
|
1335
|
+
<div className="flex justify-center py-20">
|
|
1336
|
+
<span className="animate-spin text-[var(--kyro-primary)]">⌛</span>
|
|
1337
|
+
</div>
|
|
1338
|
+
) : versions.length === 0 ? (
|
|
1339
|
+
<p className="opacity-50 font-medium py-12 text-center italic">
|
|
1340
|
+
No previous versions found for this document.
|
|
1341
|
+
</p>
|
|
1342
|
+
) : (
|
|
1343
|
+
<div className="space-y-4">
|
|
1344
|
+
{versions.map((v, i) => (
|
|
1345
|
+
<div
|
|
1346
|
+
key={v.id}
|
|
1347
|
+
className="p-6 rounded-2xl border border-[var(--kyro-border)] bg-[var(--kyro-bg-secondary)] flex items-center justify-between group hover:border-[var(--kyro-primary)] transition-all duration-300"
|
|
1348
|
+
>
|
|
1349
|
+
<div className="flex items-center gap-6">
|
|
1350
|
+
<div className="w-12 h-12 rounded-2xl bg-[var(--kyro-bg-secondary)] flex items-center justify-center border border-[var(--kyro-border)]">
|
|
1351
|
+
<span className="text-[10px] font-black opacity-40">
|
|
1352
|
+
v{versions.length - i}
|
|
1353
|
+
</span>
|
|
1354
|
+
</div>
|
|
1355
|
+
<div>
|
|
1356
|
+
<div className="flex items-center gap-3 mb-1">
|
|
1357
|
+
<span className="text-sm font-black text-[var(--kyro-text-primary)]">
|
|
1358
|
+
{new Date(v.createdAt).toLocaleString("en-US", {
|
|
1359
|
+
month: "short",
|
|
1360
|
+
day: "numeric",
|
|
1361
|
+
year: "numeric",
|
|
1362
|
+
hour: "2-digit",
|
|
1363
|
+
minute: "2-digit",
|
|
1364
|
+
})}
|
|
1365
|
+
</span>
|
|
1366
|
+
</div>
|
|
1367
|
+
<p className="text-[11px] text-[var(--kyro-text-secondary)] font-medium italic opacity-60">
|
|
1368
|
+
System captured snapshot
|
|
1369
|
+
</p>
|
|
1370
|
+
</div>
|
|
1371
|
+
</div>
|
|
1372
|
+
<div className="flex items-center gap-3">
|
|
1373
|
+
<button
|
|
1374
|
+
type="button"
|
|
1375
|
+
onClick={() => handleRestoreVersion(v.id)}
|
|
1376
|
+
className="px-5 py-2.5 rounded-xl border border-[var(--kyro-border)] text-[var(--kyro-text-primary)] text-xs font-black uppercase tracking-widest hover:bg-[var(--kyro-primary)] hover:text-white hover:border-[var(--kyro-primary)] transition-all active:scale-95 shadow-lg group-hover:shadow-[var(--kyro-primary)]"
|
|
1377
|
+
>
|
|
1378
|
+
Restore
|
|
1379
|
+
</button>
|
|
1380
|
+
</div>
|
|
1381
|
+
</div>
|
|
1382
|
+
))}
|
|
1383
|
+
</div>
|
|
1384
|
+
)}
|
|
1385
|
+
</div>
|
|
737
1386
|
</div>
|
|
738
1387
|
);
|
|
739
|
-
}
|
|
740
1388
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
className="
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1389
|
+
const renderApiView = () => (
|
|
1390
|
+
<div className="w-full space-y-8 animate-in fade-in slide-in-from-bottom-4">
|
|
1391
|
+
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-8">
|
|
1392
|
+
<div className="surface-tile p-8 min-w-0">
|
|
1393
|
+
<h2 className="text-xl font-black mb-6">Response Payload</h2>
|
|
1394
|
+
<div className="bg-[#0f172a] p-6 rounded-2xl border border-white/5 overflow-x-auto max-h-[800px]">
|
|
1395
|
+
<pre className="text-blue-300 text-xs font-mono whitespace-pre-wrap break-all">
|
|
1396
|
+
{JSON.stringify(formData, null, 2)}
|
|
1397
|
+
</pre>
|
|
1398
|
+
</div>
|
|
1399
|
+
</div>
|
|
1400
|
+
|
|
1401
|
+
<div className="space-y-6">
|
|
1402
|
+
<div className="surface-tile p-8 space-y-6">
|
|
1403
|
+
<h2 className="text-xl font-black mb-6">API Info</h2>
|
|
1404
|
+
|
|
1405
|
+
<div className="space-y-6">
|
|
1406
|
+
<div>
|
|
1407
|
+
<label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
|
|
1408
|
+
Reference Path
|
|
1409
|
+
</label>
|
|
1410
|
+
<div className="relative group">
|
|
1411
|
+
<code className="block bg-[var(--kyro-bg-secondary)] p-4 rounded-xl border border-[var(--kyro-border)] text-[var(--kyro-text-primary)] text-xs font-mono break-all leading-relaxed">
|
|
1412
|
+
{`/api/${collectionSlug}/${formData.id || ""}`}
|
|
1413
|
+
</code>
|
|
1414
|
+
</div>
|
|
1415
|
+
</div>
|
|
1416
|
+
|
|
1417
|
+
<div>
|
|
1418
|
+
<label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-3">
|
|
1419
|
+
Methods Allowed
|
|
1420
|
+
</label>
|
|
1421
|
+
<div className="flex gap-2">
|
|
1422
|
+
<span className="px-3 py-1.5 bg-green-500/10 text-green-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
|
|
1423
|
+
GET
|
|
1424
|
+
</span>
|
|
1425
|
+
<span className="px-3 py-1.5 bg-amber-500/10 text-amber-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
|
|
1426
|
+
PATCH
|
|
1427
|
+
</span>
|
|
1428
|
+
<span className="px-3 py-1.5 bg-red-500/10 text-red-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
|
|
1429
|
+
DELETE
|
|
1430
|
+
</span>
|
|
1431
|
+
</div>
|
|
1432
|
+
</div>
|
|
1433
|
+
|
|
1434
|
+
<div className="pt-6 border-t border-[var(--kyro-border)]">
|
|
1435
|
+
<label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-4">
|
|
1436
|
+
Security Policy
|
|
1437
|
+
</label>
|
|
1438
|
+
<div className="space-y-3">
|
|
1439
|
+
{[
|
|
1440
|
+
{
|
|
1441
|
+
id: "auth-required",
|
|
1442
|
+
label: "Authorization required",
|
|
1443
|
+
checked: true,
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
id: "auth-admin",
|
|
1447
|
+
label: "System administrator only",
|
|
1448
|
+
checked: false,
|
|
1449
|
+
},
|
|
1450
|
+
{
|
|
1451
|
+
id: "auth-api",
|
|
1452
|
+
label: "API Key authentication allowed",
|
|
1453
|
+
checked: true,
|
|
1454
|
+
},
|
|
1455
|
+
].map((item) => (
|
|
1456
|
+
<label
|
|
1457
|
+
key={item.id}
|
|
1458
|
+
className="flex items-center gap-3 cursor-pointer group"
|
|
1459
|
+
>
|
|
1460
|
+
<div
|
|
1461
|
+
className={`w-4 h-4 rounded border transition-all flex items-center justify-center ${item.checked ? "bg-[var(--kyro-primary)] border-[var(--kyro-primary)]" : "border-[var(--kyro-border)] group-hover:border-[var(--kyro-text-secondary)]"}`}
|
|
1462
|
+
>
|
|
1463
|
+
{item.checked && (
|
|
1464
|
+
<svg
|
|
1465
|
+
width="10"
|
|
1466
|
+
height="10"
|
|
1467
|
+
viewBox="0 0 24 24"
|
|
1468
|
+
fill="none"
|
|
1469
|
+
stroke="white"
|
|
1470
|
+
strokeWidth="4"
|
|
1471
|
+
>
|
|
1472
|
+
<path d="M20 6L9 17l-5-5" />
|
|
1473
|
+
</svg>
|
|
1474
|
+
)}
|
|
1475
|
+
</div>
|
|
1476
|
+
<span className="text-xs font-medium text-[var(--kyro-text-secondary)] group-hover:text-[var(--kyro-text-primary)] transition-colors">
|
|
1477
|
+
{item.label}
|
|
1478
|
+
</span>
|
|
1479
|
+
</label>
|
|
1480
|
+
))}
|
|
1481
|
+
</div>
|
|
1482
|
+
</div>
|
|
1483
|
+
|
|
1484
|
+
<div className="pt-6 border-t border-[var(--kyro-border)]">
|
|
1485
|
+
<label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
|
|
1486
|
+
Usage Help
|
|
1487
|
+
</label>
|
|
1488
|
+
<p className="text-[11px] text-[var(--kyro-text-secondary)] leading-relaxed">
|
|
1489
|
+
Include the{" "}
|
|
1490
|
+
<code className="text-[var(--kyro-text-primary)] font-bold">
|
|
1491
|
+
Authorization: Bearer <token>
|
|
1492
|
+
</code>{" "}
|
|
1493
|
+
header to perform write operations on this document.
|
|
1494
|
+
</p>
|
|
1495
|
+
</div>
|
|
1496
|
+
</div>
|
|
1497
|
+
</div>
|
|
1498
|
+
</div>
|
|
1499
|
+
</div>
|
|
1500
|
+
</div>
|
|
1501
|
+
);
|
|
1502
|
+
|
|
1503
|
+
return (
|
|
1504
|
+
<div className="flex flex-col h-full">
|
|
1505
|
+
{layout !== "single" && renderHeader()}
|
|
1506
|
+
<main className="w-full">
|
|
1507
|
+
{view === "edit" && renderEditView()}
|
|
1508
|
+
{view === "version" && renderVersionView()}
|
|
1509
|
+
{view === "api" && renderApiView()}
|
|
1510
|
+
</main>
|
|
1511
|
+
</div>
|
|
1512
|
+
);
|
|
816
1513
|
}
|
|
817
1514
|
|
|
818
1515
|
interface UploadFieldProps {
|
|
@@ -821,6 +1518,7 @@ interface UploadFieldProps {
|
|
|
821
1518
|
onChange: (value: any) => void;
|
|
822
1519
|
disabled?: boolean;
|
|
823
1520
|
error?: string;
|
|
1521
|
+
documentName?: string;
|
|
824
1522
|
}
|
|
825
1523
|
|
|
826
1524
|
function UploadField({
|
|
@@ -829,6 +1527,7 @@ function UploadField({
|
|
|
829
1527
|
onChange,
|
|
830
1528
|
disabled,
|
|
831
1529
|
error,
|
|
1530
|
+
documentName,
|
|
832
1531
|
}: UploadFieldProps) {
|
|
833
1532
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
834
1533
|
const [preview, setPreview] = useState<string | null>(null);
|
|
@@ -963,33 +1662,33 @@ function RelationshipField({
|
|
|
963
1662
|
? field.relationTo[0]
|
|
964
1663
|
: field.relationTo;
|
|
965
1664
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
.
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
}
|
|
980
|
-
}, [isOpen, targetCollection]);
|
|
1665
|
+
const fetchOptions = () => {
|
|
1666
|
+
setLoading(true);
|
|
1667
|
+
fetch(`/api/${targetCollection}?limit=50`)
|
|
1668
|
+
.then((res) => res.json())
|
|
1669
|
+
.then((data) => {
|
|
1670
|
+
setOptions(data.docs || []);
|
|
1671
|
+
setLoading(false);
|
|
1672
|
+
})
|
|
1673
|
+
.catch((err) => {
|
|
1674
|
+
console.error("Failed to fetch relations:", err);
|
|
1675
|
+
setLoading(false);
|
|
1676
|
+
});
|
|
1677
|
+
};
|
|
981
1678
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
return searchableFields.some(
|
|
986
|
-
(key) => opt[key] && String(opt[key]).toLowerCase().includes(term),
|
|
987
|
-
);
|
|
988
|
-
});
|
|
1679
|
+
useEffect(() => {
|
|
1680
|
+
fetchOptions();
|
|
1681
|
+
}, [targetCollection]);
|
|
989
1682
|
|
|
990
1683
|
const getLabel = (opt: any) => {
|
|
991
1684
|
if (!opt) return "";
|
|
992
|
-
return
|
|
1685
|
+
return (
|
|
1686
|
+
opt.title || opt.name || opt.label || opt.filename || opt.slug || opt.id
|
|
1687
|
+
);
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
const findOptionById = (id: string) => {
|
|
1691
|
+
return options.find((opt) => opt.id === id);
|
|
993
1692
|
};
|
|
994
1693
|
|
|
995
1694
|
const isSelected = (optId: string) => {
|
|
@@ -1018,6 +1717,33 @@ function RelationshipField({
|
|
|
1018
1717
|
}
|
|
1019
1718
|
};
|
|
1020
1719
|
|
|
1720
|
+
const renderSelectedValue = () => {
|
|
1721
|
+
if (!value) return null;
|
|
1722
|
+
if (isMultiple && Array.isArray(value)) {
|
|
1723
|
+
if (value.length === 0) return "None selected";
|
|
1724
|
+
return value
|
|
1725
|
+
.map((v) => {
|
|
1726
|
+
const id = v.id || v;
|
|
1727
|
+
const opt = findOptionById(id);
|
|
1728
|
+
return opt ? getLabel(opt) : id;
|
|
1729
|
+
})
|
|
1730
|
+
.join(", ");
|
|
1731
|
+
}
|
|
1732
|
+
const id = value.id || value;
|
|
1733
|
+
const opt = findOptionById(id);
|
|
1734
|
+
return opt ? getLabel(opt) : id;
|
|
1735
|
+
};
|
|
1736
|
+
|
|
1737
|
+
const filteredOptions = search
|
|
1738
|
+
? (options || []).filter((opt) => {
|
|
1739
|
+
const term = search.toLowerCase();
|
|
1740
|
+
const searchableFields = ["title", "name", "label", "filename", "slug"];
|
|
1741
|
+
return searchableFields.some(
|
|
1742
|
+
(key) => opt[key] && String(opt[key]).toLowerCase().includes(term),
|
|
1743
|
+
);
|
|
1744
|
+
})
|
|
1745
|
+
: options || [];
|
|
1746
|
+
|
|
1021
1747
|
return (
|
|
1022
1748
|
<div className="kyro-form-field">
|
|
1023
1749
|
<label className="kyro-form-label">
|
|
@@ -1041,15 +1767,7 @@ function RelationshipField({
|
|
|
1041
1767
|
|
|
1042
1768
|
<div className="kyro-form-relationship-value">
|
|
1043
1769
|
{value ? (
|
|
1044
|
-
|
|
1045
|
-
value.length > 0 ? (
|
|
1046
|
-
`${value.length} items selected`
|
|
1047
|
-
) : (
|
|
1048
|
-
"None selected"
|
|
1049
|
-
)
|
|
1050
|
-
) : (
|
|
1051
|
-
`Selected: ${getLabel(value) || value.id || value}`
|
|
1052
|
-
)
|
|
1770
|
+
renderSelectedValue()
|
|
1053
1771
|
) : (
|
|
1054
1772
|
<span className="kyro-form-relationship-empty">
|
|
1055
1773
|
Click to search and select...
|
|
@@ -1099,7 +1817,7 @@ function RelationshipField({
|
|
|
1099
1817
|
>
|
|
1100
1818
|
<span>{getLabel(opt)}</span>
|
|
1101
1819
|
<span className="kyro-relation-modal-item-id">
|
|
1102
|
-
({opt.id.slice(0, 8)}...)
|
|
1820
|
+
{opt.id ? `(${String(opt.id).slice(0, 8)}...)` : ""}
|
|
1103
1821
|
</span>
|
|
1104
1822
|
</button>
|
|
1105
1823
|
))
|
|
@@ -1121,3 +1839,41 @@ function RelationshipField({
|
|
|
1121
1839
|
</div>
|
|
1122
1840
|
);
|
|
1123
1841
|
}
|
|
1842
|
+
|
|
1843
|
+
// SEO Utilities
|
|
1844
|
+
function stripHtml(html: string) {
|
|
1845
|
+
if (typeof html !== "string") return "";
|
|
1846
|
+
return html.replace(/<[^>]*>?/gm, "").trim();
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
const SeoPreview = ({
|
|
1850
|
+
title,
|
|
1851
|
+
description,
|
|
1852
|
+
slug,
|
|
1853
|
+
}: {
|
|
1854
|
+
title: string;
|
|
1855
|
+
description: string;
|
|
1856
|
+
slug: string;
|
|
1857
|
+
}) => (
|
|
1858
|
+
<div className="bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg p-6 max-w-2xl shadow-sm transition-colors duration-300">
|
|
1859
|
+
<div className="flex items-center gap-2 mb-2">
|
|
1860
|
+
<div className="w-7 h-7 bg-[var(--kyro-bg-secondary)] rounded-full flex items-center justify-center text-[10px] text-[var(--kyro-text-primary)] font-medium border border-[var(--kyro-border)]">
|
|
1861
|
+
K
|
|
1862
|
+
</div>
|
|
1863
|
+
<div className="flex flex-col">
|
|
1864
|
+
<span className="text-sm font-medium text-[var(--kyro-text-primary)] leading-tight">
|
|
1865
|
+
kyro-cms.com
|
|
1866
|
+
</span>
|
|
1867
|
+
<span className="text-[12px] text-[var(--kyro-text-secondary)] leading-tight opacity-80">
|
|
1868
|
+
https://kyro-cms.com › posts › {slug}
|
|
1869
|
+
</span>
|
|
1870
|
+
</div>
|
|
1871
|
+
</div>
|
|
1872
|
+
<h3 className="text-[20px] text-[#2563eb] dark:text-[#60a5fa] font-medium hover:underline cursor-pointer mb-1 leading-tight transition-colors">
|
|
1873
|
+
{title}
|
|
1874
|
+
</h3>
|
|
1875
|
+
<p className="text-[14px] text-[var(--kyro-text-secondary)] leading-relaxed line-clamp-2">
|
|
1876
|
+
{description}
|
|
1877
|
+
</p>
|
|
1878
|
+
</div>
|
|
1879
|
+
);
|