@kyro-cms/admin 0.1.6 → 0.1.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/README.md +149 -51
- package/package.json +54 -5
- 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 +137 -28
- 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 +2155 -770
- package/src/components/BrandingHub.tsx +267 -0
- package/src/components/BulkActionsBar.tsx +3 -3
- package/src/components/CreateView.tsx +4 -4
- package/src/components/Dashboard.tsx +393 -0
- package/src/components/DetailView.tsx +200 -58
- package/src/components/DeveloperCenter.tsx +403 -0
- package/src/components/EnhancedListView.tsx +890 -0
- package/src/components/GraphQLExplorer.tsx +675 -0
- package/src/components/GraphQLPlayground.tsx +627 -0
- package/src/components/ListView.tsx +192 -54
- package/src/components/MediaGallery.tsx +1569 -0
- package/src/components/Modal.tsx +206 -0
- package/src/components/RestPlayground.tsx +951 -0
- package/src/components/Sidebar.astro +237 -0
- package/src/components/ThemeProvider.tsx +8 -2
- 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 +65 -0
- package/src/components/blocks/ArrayBlock.tsx +84 -0
- package/src/components/blocks/BlockEditModal.tsx +363 -0
- package/src/components/blocks/ButtonBlock.tsx +64 -0
- package/src/components/blocks/ChildBlocksTree.tsx +551 -0
- package/src/components/blocks/CodeBlock.tsx +114 -0
- package/src/components/blocks/ColumnsBlock.tsx +93 -0
- package/src/components/blocks/DividerBlock.tsx +43 -0
- package/src/components/blocks/FileBlock.tsx +63 -0
- package/src/components/blocks/HeadingBlock.tsx +59 -0
- package/src/components/blocks/HeroBlock.tsx +99 -0
- package/src/components/blocks/ImageBlock.tsx +82 -0
- package/src/components/blocks/LinkBlock.tsx +65 -0
- package/src/components/blocks/ListBlock.tsx +60 -0
- package/src/components/blocks/ParagraphBlock.tsx +61 -0
- package/src/components/blocks/RelationshipBlock.tsx +72 -0
- package/src/components/blocks/RichTextBlock.tsx +66 -0
- package/src/components/blocks/VStackBlock.tsx +61 -0
- package/src/components/blocks/VideoBlock.tsx +65 -0
- package/src/components/blocks/index.ts +10 -0
- package/src/components/fields/AccordionField.tsx +213 -0
- package/src/components/fields/ArrayField.tsx +241 -0
- package/src/components/fields/BlocksField.tsx +323 -0
- package/src/components/fields/ButtonField.tsx +53 -0
- package/src/components/fields/CheckboxField.tsx +18 -8
- package/src/components/fields/ChildrenField.tsx +48 -0
- package/src/components/fields/CodeField.tsx +294 -0
- package/src/components/fields/ColumnsField.tsx +137 -0
- package/src/components/fields/DateField.tsx +24 -12
- package/src/components/fields/EditorClient.tsx +537 -0
- package/src/components/fields/HeadingField.tsx +31 -0
- package/src/components/fields/HeroField.tsx +101 -0
- package/src/components/fields/JSONField.tsx +341 -0
- package/src/components/fields/LinkField.tsx +81 -0
- package/src/components/fields/ListField.tsx +74 -0
- package/src/components/fields/MarkdownField.tsx +260 -0
- package/src/components/fields/NumberField.tsx +25 -13
- package/src/components/fields/PortableTextField.tsx +155 -0
- package/src/components/fields/PortableTextRenderer.tsx +68 -0
- package/src/components/fields/RelationshipBlockField.tsx +233 -0
- package/src/components/fields/RelationshipField.tsx +278 -60
- package/src/components/fields/SelectField.tsx +28 -16
- package/src/components/fields/TextField.tsx +31 -15
- package/src/components/fields/UploadField.tsx +613 -0
- package/src/components/fields/VideoField.tsx +73 -0
- package/src/components/fields/extensions/blockComponents.tsx +247 -0
- package/src/components/fields/extensions/blocksStore.ts +273 -0
- package/src/components/fields/index.ts +24 -0
- package/src/components/index.ts +1 -2
- package/src/components/layout/Header.tsx +2 -2
- package/src/components/layout/Layout.tsx +3 -3
- 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/api.ts +163 -0
- package/src/lib/auth/sqlite-adapter.ts +319 -0
- package/src/lib/config.ts +23 -7
- package/src/lib/dataStore.ts +188 -73
- package/src/lib/date-utils.ts +69 -0
- 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/db/version-adapter.ts +248 -0
- package/src/lib/graphql/index.ts +1 -0
- package/src/lib/graphql/schema.ts +443 -0
- package/src/lib/i18n.tsx +353 -0
- package/src/lib/rate-limit.ts +267 -0
- package/src/lib/slugify.ts +15 -0
- package/src/lib/storage.ts +374 -0
- package/src/lib/store.ts +85 -0
- package/src/lib/validation.ts +250 -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 +52 -0
- package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
- package/src/pages/api/[collection]/[id]/versions.ts +66 -0
- package/src/pages/api/[collection]/[id].ts +114 -159
- package/src/pages/api/[collection]/index.ts +150 -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 +563 -128
- package/src/components/layout/Sidebar.tsx +0 -497
|
@@ -1,144 +1,969 @@
|
|
|
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/client";
|
|
8
|
+
import { UploadField } from "./fields/UploadField";
|
|
9
|
+
import { CodeField } 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 { globals, collections } from "@/lib/config";
|
|
16
|
+
import { slugifyText } from "@/lib/slugify";
|
|
17
|
+
|
|
18
|
+
import { BlocksField } from "./fields/BlocksField";
|
|
19
|
+
import PortableTextField from "./fields/PortableTextField";
|
|
20
|
+
import { ConfirmModal, UIModal } from "./Modal";
|
|
3
21
|
|
|
4
22
|
interface AutoFormProps {
|
|
5
|
-
config: CollectionConfig;
|
|
23
|
+
config: CollectionConfig | GlobalConfig;
|
|
6
24
|
data?: Record<string, any>;
|
|
7
25
|
errors?: Record<string, string>;
|
|
8
26
|
onChange?: (data: Record<string, any>) => void;
|
|
9
27
|
disabled?: boolean;
|
|
10
28
|
collectionSlug?: string;
|
|
29
|
+
globalSlug?: string;
|
|
30
|
+
documentName?: string;
|
|
31
|
+
layout?: "split" | "single";
|
|
32
|
+
onActionSuccess?: (message: string) => void;
|
|
33
|
+
onActionError?: (message: string) => void;
|
|
34
|
+
documentStatus?: "draft" | "published";
|
|
35
|
+
justSaved?: boolean;
|
|
11
36
|
}
|
|
12
37
|
|
|
13
38
|
export function AutoForm({
|
|
14
|
-
config,
|
|
15
|
-
data = {},
|
|
39
|
+
config: propConfig,
|
|
40
|
+
data: initialData = {},
|
|
16
41
|
errors = {},
|
|
17
42
|
onChange,
|
|
18
|
-
disabled,
|
|
43
|
+
disabled: propDisabled,
|
|
44
|
+
collectionSlug,
|
|
45
|
+
globalSlug,
|
|
46
|
+
documentName,
|
|
47
|
+
layout = "split",
|
|
48
|
+
onActionSuccess,
|
|
49
|
+
onActionError,
|
|
50
|
+
documentStatus,
|
|
51
|
+
justSaved,
|
|
19
52
|
}: AutoFormProps) {
|
|
53
|
+
// Resolve the "live" config to preserve functions (admin.condition) lost during prop serialization
|
|
54
|
+
const activeConfig = globalSlug
|
|
55
|
+
? globals[globalSlug]
|
|
56
|
+
: collectionSlug
|
|
57
|
+
? collections[collectionSlug]
|
|
58
|
+
: propConfig;
|
|
59
|
+
const config = activeConfig || propConfig;
|
|
60
|
+
|
|
61
|
+
// Helper to extract default values from config recursively
|
|
62
|
+
function getDefaults(fields: any[], prefix = ""): Record<string, any> {
|
|
63
|
+
const defaults: Record<string, any> = {};
|
|
64
|
+
for (const field of fields || []) {
|
|
65
|
+
if (field.defaultValue !== undefined) {
|
|
66
|
+
const key = prefix + field.name;
|
|
67
|
+
defaults[key] = field.defaultValue;
|
|
68
|
+
// Also set nested defaults for groups
|
|
69
|
+
if (field.type === "group" && field.fields) {
|
|
70
|
+
for (const subField of field.fields) {
|
|
71
|
+
if (subField.defaultValue !== undefined) {
|
|
72
|
+
defaults[prefix + field.name + "." + subField.name] =
|
|
73
|
+
subField.defaultValue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (field.fields && Array.isArray(field.fields)) {
|
|
79
|
+
Object.assign(defaults, getDefaults(field.fields, field.name + "."));
|
|
80
|
+
}
|
|
81
|
+
if (field.tabs) {
|
|
82
|
+
for (const tab of field.tabs) {
|
|
83
|
+
if (tab.fields) {
|
|
84
|
+
Object.assign(defaults, getDefaults(tab.fields, prefix));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return defaults;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Helper to flatten nested object with dot notation keys
|
|
93
|
+
function flattenObject(
|
|
94
|
+
obj: Record<string, any>,
|
|
95
|
+
prefix = "",
|
|
96
|
+
): Record<string, any> {
|
|
97
|
+
const result: Record<string, any> = {};
|
|
98
|
+
for (const key in obj) {
|
|
99
|
+
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
100
|
+
const val = obj[key];
|
|
101
|
+
if (
|
|
102
|
+
val !== null &&
|
|
103
|
+
typeof val === "object" &&
|
|
104
|
+
!Array.isArray(val) &&
|
|
105
|
+
// Only recurse into plain objects, not Dates, Maps, or other class instances
|
|
106
|
+
(val.constructor === Object || !val.constructor)
|
|
107
|
+
) {
|
|
108
|
+
Object.assign(result, flattenObject(val, newKey));
|
|
109
|
+
} else {
|
|
110
|
+
result[newKey] = val;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Helper to unflatten dot notation keys back to nested object
|
|
117
|
+
function unflattenObject(flat: Record<string, any>): Record<string, any> {
|
|
118
|
+
const result: Record<string, any> = {};
|
|
119
|
+
for (const key in flat) {
|
|
120
|
+
const parts = key.split(".");
|
|
121
|
+
let current = result;
|
|
122
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
123
|
+
if (!current[parts[i]]) {
|
|
124
|
+
current[parts[i]] = {};
|
|
125
|
+
}
|
|
126
|
+
current = current[parts[i]];
|
|
127
|
+
}
|
|
128
|
+
current[parts[parts.length - 1]] = flat[key];
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Merge initial data with defaults from config
|
|
134
|
+
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
try {
|
|
138
|
+
const configDefaults = config ? getDefaults(config.fields) : {};
|
|
139
|
+
const flatInitialData = flattenObject(initialData || {});
|
|
140
|
+
const mergedFlatData = { ...configDefaults, ...flatInitialData };
|
|
141
|
+
const mergedInitialData = unflattenObject(mergedFlatData);
|
|
142
|
+
setFormData(mergedInitialData);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
console.error("Critical error in AutoForm data initialization:", e);
|
|
145
|
+
// Fallback to raw initialData if flattening fails
|
|
146
|
+
setFormData(initialData || {});
|
|
147
|
+
}
|
|
148
|
+
}, [initialData, config]);
|
|
149
|
+
const [activeTab, setActiveTab] = useState(0);
|
|
150
|
+
const [isSlugLocked, setIsSlugLocked] = useState(true);
|
|
151
|
+
const [view, setView] = useState<"edit" | "version" | "api">("edit");
|
|
152
|
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
153
|
+
const [versions, setVersions] = useState<any[]>([]);
|
|
154
|
+
const [loadingVersions, setLoadingVersions] = useState(false);
|
|
155
|
+
const [showPreview, setShowPreview] = useState(false);
|
|
156
|
+
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
157
|
+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
158
|
+
const [loadingFields, setLoadingFields] = useState<Record<string, boolean>>(
|
|
159
|
+
{},
|
|
160
|
+
);
|
|
161
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
162
|
+
const [compareMode, setCompareMode] = useState(false);
|
|
163
|
+
const [compareSelected, setCompareSelected] = useState<string[]>([]);
|
|
164
|
+
const [compareDiffs, setCompareDiffs] = useState<any[]>([]);
|
|
165
|
+
const [loadingDiffs, setLoadingDiffs] = useState(false);
|
|
166
|
+
const [confirmModal, setConfirmModal] = useState<{
|
|
167
|
+
open: boolean;
|
|
168
|
+
title: string;
|
|
169
|
+
message: string;
|
|
170
|
+
onConfirm: () => void;
|
|
171
|
+
danger?: boolean;
|
|
172
|
+
}>({ open: false, title: "", message: "", onConfirm: () => {} });
|
|
173
|
+
const [alertModal, setAlertModal] = useState<{
|
|
174
|
+
open: boolean;
|
|
175
|
+
title: string;
|
|
176
|
+
message: string;
|
|
177
|
+
}>({ open: false, title: "", message: "" });
|
|
178
|
+
const [lastSavedData, setLastSavedData] = useState<Record<string, any>>({});
|
|
179
|
+
const [isAutoSaving, setIsAutoSaving] = useState(false);
|
|
180
|
+
const [autoSaveStatus, setAutoSaveStatus] = useState<
|
|
181
|
+
"idle" | "saving" | "saved" | "error"
|
|
182
|
+
>("idle");
|
|
183
|
+
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
184
|
+
const lastAutoSaveTimeRef = useRef<number>(0);
|
|
185
|
+
const autoSaveSkipRef = useRef<boolean>(false);
|
|
186
|
+
|
|
187
|
+
const disabled = propDisabled;
|
|
188
|
+
|
|
189
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
const handleToggle = () => {
|
|
193
|
+
setSidebarCollapsed((prev) => !prev);
|
|
194
|
+
};
|
|
195
|
+
window.addEventListener("toggle-sidebar", handleToggle);
|
|
196
|
+
return () => window.removeEventListener("toggle-sidebar", handleToggle);
|
|
197
|
+
}, []);
|
|
198
|
+
|
|
199
|
+
// Track unsaved changes (compare against last saved state)
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const isDifferent =
|
|
202
|
+
JSON.stringify(formData) !== JSON.stringify(lastSavedData);
|
|
203
|
+
setHasUnsavedChanges(isDifferent);
|
|
204
|
+
}, [formData, lastSavedData]);
|
|
205
|
+
|
|
206
|
+
// Auto-generate slug from configured source field if locked
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
const slugField = config.fields.find(
|
|
209
|
+
(f: any) => f.name === "slug" && f.admin?.autoGenerate,
|
|
210
|
+
);
|
|
211
|
+
if (!slugField?.admin?.autoGenerate) return;
|
|
212
|
+
const sourceField: string = slugField.admin.autoGenerate;
|
|
213
|
+
if (isSlugLocked && formData[sourceField]) {
|
|
214
|
+
const newSlug = slugifyText(formData[sourceField]);
|
|
215
|
+
if (newSlug !== formData.slug) {
|
|
216
|
+
setFormData((prev) => ({ ...prev, slug: newSlug }));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}, [
|
|
220
|
+
formData.title,
|
|
221
|
+
formData.name,
|
|
222
|
+
formData.label,
|
|
223
|
+
isSlugLocked,
|
|
224
|
+
config.fields,
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
// Sync prop changes to local state
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (initialData && Object.keys(initialData).length > 0) {
|
|
230
|
+
setFormData(initialData);
|
|
231
|
+
setLastSavedData(initialData);
|
|
232
|
+
}
|
|
233
|
+
}, [initialData]);
|
|
234
|
+
|
|
235
|
+
// Auto-save with Strategy 3: 1s debounce, lastSavedData comparison, 15s hard throttle
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!formData.id || sidebarCollapsed) return;
|
|
238
|
+
|
|
239
|
+
if (autoSaveTimerRef.current) {
|
|
240
|
+
clearTimeout(autoSaveTimerRef.current);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
const timeSinceLastSave = now - lastAutoSaveTimeRef.current;
|
|
245
|
+
const hasChanges =
|
|
246
|
+
JSON.stringify(formData) !== JSON.stringify(lastSavedData);
|
|
247
|
+
|
|
248
|
+
if (!hasChanges) {
|
|
249
|
+
setAutoSaveStatus("idle");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (timeSinceLastSave < 15000 && lastAutoSaveTimeRef.current > 0) {
|
|
254
|
+
const remainingTime = Math.max(1000, 15000 - timeSinceLastSave);
|
|
255
|
+
autoSaveTimerRef.current = setTimeout(async () => {
|
|
256
|
+
await performAutoSave();
|
|
257
|
+
}, remainingTime);
|
|
258
|
+
} else {
|
|
259
|
+
autoSaveTimerRef.current = setTimeout(async () => {
|
|
260
|
+
await performAutoSave();
|
|
261
|
+
}, 1000);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return () => {
|
|
265
|
+
if (autoSaveTimerRef.current) {
|
|
266
|
+
clearTimeout(autoSaveTimerRef.current);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}, [formData]);
|
|
270
|
+
|
|
271
|
+
const performAutoSave = async () => {
|
|
272
|
+
if (autoSaveSkipRef.current) return;
|
|
273
|
+
if (JSON.stringify(formData) === JSON.stringify(lastSavedData)) return;
|
|
274
|
+
|
|
275
|
+
setIsAutoSaving(true);
|
|
276
|
+
setAutoSaveStatus("saving");
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const { id, createdAt, updatedAt, ...rest } = formData;
|
|
280
|
+
const saveData = {
|
|
281
|
+
...rest,
|
|
282
|
+
_changeDescription: "Auto-saved",
|
|
283
|
+
status: formData.status === "published" ? "draft" : formData.status,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const response = await fetch(`/api/${collectionSlug}/${formData.id}`, {
|
|
287
|
+
method: "PATCH",
|
|
288
|
+
credentials: "include",
|
|
289
|
+
headers: { "Content-Type": "application/json" },
|
|
290
|
+
body: JSON.stringify(saveData),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (response.ok) {
|
|
294
|
+
const result = await response.json();
|
|
295
|
+
setLastSavedData(result.data || formData);
|
|
296
|
+
lastAutoSaveTimeRef.current = Date.now();
|
|
297
|
+
setAutoSaveStatus("saved");
|
|
298
|
+
fetchVersions();
|
|
299
|
+
setTimeout(() => setAutoSaveStatus("idle"), 2000);
|
|
300
|
+
} else {
|
|
301
|
+
setAutoSaveStatus("error");
|
|
302
|
+
setTimeout(() => setAutoSaveStatus("idle"), 3000);
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
console.error("Auto-save failed:", err);
|
|
306
|
+
setAutoSaveStatus("error");
|
|
307
|
+
setTimeout(() => setAutoSaveStatus("idle"), 3000);
|
|
308
|
+
} finally {
|
|
309
|
+
setIsAutoSaving(false);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Sync to hidden input for Astro form submission
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
const hiddenInput = document.getElementById(
|
|
316
|
+
"form-data",
|
|
317
|
+
) as HTMLInputElement;
|
|
318
|
+
if (hiddenInput) {
|
|
319
|
+
hiddenInput.value = JSON.stringify(formData);
|
|
320
|
+
}
|
|
321
|
+
onChange?.(formData);
|
|
322
|
+
}, [formData, onChange]);
|
|
323
|
+
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
if (formData.id) fetchVersions();
|
|
326
|
+
}, [formData.id]);
|
|
327
|
+
|
|
328
|
+
const fetchVersions = async () => {
|
|
329
|
+
setLoadingVersions(true);
|
|
330
|
+
try {
|
|
331
|
+
const resp = await fetch(
|
|
332
|
+
`/api/${collectionSlug}/${formData.id}/versions`,
|
|
333
|
+
);
|
|
334
|
+
const data = await resp.json();
|
|
335
|
+
setVersions(data.docs || []);
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.error("Failed to fetch versions:", e);
|
|
338
|
+
} finally {
|
|
339
|
+
setLoadingVersions(false);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const handleRestoreVersion = async (versionId: string) => {
|
|
344
|
+
if (
|
|
345
|
+
!confirm(
|
|
346
|
+
"Are you sure you want to restore this version? This will overwrite your current changes.",
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
return;
|
|
350
|
+
try {
|
|
351
|
+
const resp = await fetch(
|
|
352
|
+
`/api/${collectionSlug}/${formData.id}/versions`,
|
|
353
|
+
{
|
|
354
|
+
method: "POST",
|
|
355
|
+
headers: { "Content-Type": "application/json" },
|
|
356
|
+
body: JSON.stringify({ versionId, action: "restore" }),
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
const result = await resp.json();
|
|
360
|
+
if (result.data) {
|
|
361
|
+
setFormData(result.data);
|
|
362
|
+
setView("edit");
|
|
363
|
+
fetchVersions();
|
|
364
|
+
}
|
|
365
|
+
} catch (e) {
|
|
366
|
+
console.error("Restore failed:", e);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const handleCompareVersions = async () => {
|
|
371
|
+
if (compareSelected.length !== 2) return;
|
|
372
|
+
setLoadingDiffs(true);
|
|
373
|
+
try {
|
|
374
|
+
const resp = await fetch(
|
|
375
|
+
`/api/${collectionSlug}/${formData.id}/versions?compareA=${compareSelected[0]}&compareB=${compareSelected[1]}`,
|
|
376
|
+
);
|
|
377
|
+
const data = await resp.json();
|
|
378
|
+
setCompareDiffs(data.diffs || []);
|
|
379
|
+
} catch (e) {
|
|
380
|
+
console.error("Compare failed:", e);
|
|
381
|
+
setCompareDiffs([]);
|
|
382
|
+
} finally {
|
|
383
|
+
setLoadingDiffs(false);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const toggleCompareSelection = (versionId: string) => {
|
|
388
|
+
setCompareSelected((prev) => {
|
|
389
|
+
if (prev.includes(versionId)) {
|
|
390
|
+
return prev.filter((id) => id !== versionId);
|
|
391
|
+
}
|
|
392
|
+
if (prev.length >= 2) {
|
|
393
|
+
return [prev[1], versionId];
|
|
394
|
+
}
|
|
395
|
+
return [...prev, versionId];
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
const handleShortcuts = (e: KeyboardEvent) => {
|
|
401
|
+
// Cmd/Ctrl + S = Publish
|
|
402
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
(document.getElementById("btn-save") as any)?.click();
|
|
405
|
+
}
|
|
406
|
+
// Cmd/Ctrl + P = Toggle Preview
|
|
407
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "p") {
|
|
408
|
+
e.preventDefault();
|
|
409
|
+
setShowPreview((prev) => !prev);
|
|
410
|
+
}
|
|
411
|
+
// Keys 1, 2, 3 = Tab Switching
|
|
412
|
+
if (
|
|
413
|
+
document.activeElement?.tagName !== "INPUT" &&
|
|
414
|
+
document.activeElement?.tagName !== "TEXTAREA"
|
|
415
|
+
) {
|
|
416
|
+
if (e.key === "1") setView("edit");
|
|
417
|
+
if (e.key === "2") setView("version");
|
|
418
|
+
if (e.key === "3") setView("api");
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
window.addEventListener("keydown", handleShortcuts);
|
|
422
|
+
return () => window.removeEventListener("keydown", handleShortcuts);
|
|
423
|
+
}, []);
|
|
424
|
+
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
427
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
428
|
+
setIsMenuOpen(false);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
if (isMenuOpen) {
|
|
432
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
433
|
+
return () =>
|
|
434
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
435
|
+
}
|
|
436
|
+
}, [isMenuOpen]);
|
|
437
|
+
|
|
438
|
+
const handleCreateNew = () => {
|
|
439
|
+
if (hasUnsavedChanges) {
|
|
440
|
+
setConfirmModal({
|
|
441
|
+
open: true,
|
|
442
|
+
title: "Unsaved Changes",
|
|
443
|
+
message: "You have unsaved changes. Save before creating new?",
|
|
444
|
+
onConfirm: async () => {
|
|
445
|
+
(document.getElementById("btn-save") as any)?.click();
|
|
446
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
447
|
+
window.location.href = `/${collectionSlug}/new`;
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
} else {
|
|
451
|
+
window.location.href = `/${collectionSlug}/new`;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const handleDuplicate = () => {
|
|
456
|
+
setConfirmModal({
|
|
457
|
+
open: true,
|
|
458
|
+
title: "Duplicate Document",
|
|
459
|
+
message: "Create a duplicate of this document?",
|
|
460
|
+
onConfirm: async () => {
|
|
461
|
+
const { id, createdAt, updatedAt, status, ...rest } = formData;
|
|
462
|
+
const duplicateData = {
|
|
463
|
+
...rest,
|
|
464
|
+
title: `${rest.title || rest.name || "Untitled"} (Copy)`,
|
|
465
|
+
};
|
|
466
|
+
try {
|
|
467
|
+
const response = await fetch(`/api/${collectionSlug}`, {
|
|
468
|
+
method: "POST",
|
|
469
|
+
credentials: "include",
|
|
470
|
+
headers: { "Content-Type": "application/json" },
|
|
471
|
+
body: JSON.stringify(duplicateData),
|
|
472
|
+
});
|
|
473
|
+
if (response.ok) {
|
|
474
|
+
const result = await response.json();
|
|
475
|
+
window.location.href = `/${collectionSlug}/${result.data.id}`;
|
|
476
|
+
} else {
|
|
477
|
+
const error = await response.json();
|
|
478
|
+
setAlertModal({
|
|
479
|
+
open: true,
|
|
480
|
+
title: "Error",
|
|
481
|
+
message: error.error || "Failed to duplicate document",
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
} catch (err) {
|
|
485
|
+
setAlertModal({
|
|
486
|
+
open: true,
|
|
487
|
+
title: "Error",
|
|
488
|
+
message: "Failed to duplicate document",
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const handleDelete = () => {
|
|
496
|
+
setConfirmModal({
|
|
497
|
+
open: true,
|
|
498
|
+
title: "Delete Document",
|
|
499
|
+
message: "Delete this document? This cannot be undone.",
|
|
500
|
+
danger: true,
|
|
501
|
+
onConfirm: () => {
|
|
502
|
+
setConfirmModal({
|
|
503
|
+
open: true,
|
|
504
|
+
title: "Confirm Deletion",
|
|
505
|
+
message: "Are you absolutely sure?",
|
|
506
|
+
danger: true,
|
|
507
|
+
onConfirm: async () => {
|
|
508
|
+
try {
|
|
509
|
+
const response = await fetch(
|
|
510
|
+
`/api/${collectionSlug}/${formData.id}`,
|
|
511
|
+
{
|
|
512
|
+
method: "DELETE",
|
|
513
|
+
credentials: "include",
|
|
514
|
+
},
|
|
515
|
+
);
|
|
516
|
+
if (response.ok) {
|
|
517
|
+
window.location.href = `/${collectionSlug}`;
|
|
518
|
+
} else {
|
|
519
|
+
const error = await response.json();
|
|
520
|
+
setAlertModal({
|
|
521
|
+
open: true,
|
|
522
|
+
title: "Error",
|
|
523
|
+
message: error.error || "Failed to delete document",
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
} catch (err) {
|
|
527
|
+
setAlertModal({
|
|
528
|
+
open: true,
|
|
529
|
+
title: "Error",
|
|
530
|
+
message: "Failed to delete document",
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const handleUnpublish = () => {
|
|
540
|
+
setConfirmModal({
|
|
541
|
+
open: true,
|
|
542
|
+
title: "Unpublish Document",
|
|
543
|
+
message: "Unpublish this document?",
|
|
544
|
+
onConfirm: async () => {
|
|
545
|
+
try {
|
|
546
|
+
const response = await fetch(
|
|
547
|
+
`/api/${collectionSlug}/${formData.id}/unpublish`,
|
|
548
|
+
{
|
|
549
|
+
method: "POST",
|
|
550
|
+
credentials: "include",
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
if (response.ok) {
|
|
554
|
+
onActionSuccess?.("Document unpublished successfully");
|
|
555
|
+
location.reload();
|
|
556
|
+
} else {
|
|
557
|
+
const error = await response.json();
|
|
558
|
+
setAlertModal({
|
|
559
|
+
open: true,
|
|
560
|
+
title: "Error",
|
|
561
|
+
message: error.error || "Failed to unpublish",
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
} catch (err) {
|
|
565
|
+
setAlertModal({
|
|
566
|
+
open: true,
|
|
567
|
+
title: "Error",
|
|
568
|
+
message: "Failed to unpublish",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
};
|
|
574
|
+
|
|
20
575
|
const handleFieldChange = (fieldName: string, value: any) => {
|
|
21
|
-
|
|
22
|
-
...
|
|
576
|
+
setFormData((prev) => ({
|
|
577
|
+
...prev,
|
|
23
578
|
[fieldName]: value,
|
|
24
|
-
});
|
|
579
|
+
}));
|
|
25
580
|
};
|
|
26
581
|
|
|
27
|
-
const renderField = (
|
|
582
|
+
const renderField = (
|
|
583
|
+
field: Field,
|
|
584
|
+
parentData?: Record<string, any>,
|
|
585
|
+
onParentChange?: (val: any) => void,
|
|
586
|
+
): React.ReactNode => {
|
|
28
587
|
if (field.admin?.hidden) return null;
|
|
29
588
|
|
|
30
|
-
const
|
|
31
|
-
|
|
589
|
+
const currentData = parentData !== undefined ? parentData : formData;
|
|
590
|
+
|
|
591
|
+
// Evaluate display condition if present
|
|
592
|
+
// For conditional fields, pass formData as the root context (first arg)
|
|
593
|
+
// and currentData as the sibling context (second arg)
|
|
594
|
+
if (field.admin?.condition && typeof field.admin.condition === "function") {
|
|
595
|
+
try {
|
|
596
|
+
const shouldShow = field.admin.condition(formData, currentData);
|
|
597
|
+
if (!shouldShow) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
} catch (e) {
|
|
601
|
+
console.warn(`Condition error for field ${field.name}:`, e);
|
|
602
|
+
// Show the field if there's an error evaluating the condition
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const value = currentData[field.name!];
|
|
32
607
|
const error = errors[field.name!];
|
|
33
608
|
|
|
609
|
+
const onFieldChange = (val: any) => {
|
|
610
|
+
if (onParentChange) {
|
|
611
|
+
onParentChange({ ...currentData, [field.name!]: val });
|
|
612
|
+
} else {
|
|
613
|
+
handleFieldChange(field.name!, val);
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
34
617
|
if (field.type === "row" && "fields" in field) {
|
|
35
618
|
return (
|
|
36
619
|
<div
|
|
37
620
|
key={field.name || `row-${Math.random()}`}
|
|
38
|
-
className="kyro-form-row"
|
|
621
|
+
className="kyro-form-row flex gap-6 items-end"
|
|
39
622
|
>
|
|
40
|
-
{(field as any).fields.map((f: Field) =>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
623
|
+
{(field as any).fields.map((f: Field) => {
|
|
624
|
+
const fAdmin = f.admin;
|
|
625
|
+
const actionUrl = fAdmin?.action;
|
|
44
626
|
|
|
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
|
-
}
|
|
627
|
+
if (f.type === "button" && actionUrl) {
|
|
628
|
+
const siblingEmailField = (field as any).fields?.find(
|
|
629
|
+
(ff: Field) => ff.type === "email",
|
|
630
|
+
);
|
|
631
|
+
return (
|
|
632
|
+
<div key={f.name} className="flex-shrink-0">
|
|
633
|
+
<button
|
|
634
|
+
type="button"
|
|
635
|
+
disabled={disabled}
|
|
636
|
+
onClick={async () => {
|
|
637
|
+
const rowName = field.name;
|
|
638
|
+
const emailFieldName = siblingEmailField?.name;
|
|
639
|
+
let emailValue = formData[emailFieldName];
|
|
640
|
+
if (!emailValue && rowName) {
|
|
641
|
+
emailValue = formData[rowName]?.[emailFieldName];
|
|
642
|
+
}
|
|
643
|
+
if (!emailValue) return;
|
|
72
644
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
645
|
+
setLoadingFields((prev) => ({
|
|
646
|
+
...prev,
|
|
647
|
+
[f.name!]: true,
|
|
648
|
+
}));
|
|
649
|
+
try {
|
|
650
|
+
const response = await fetch(actionUrl, {
|
|
651
|
+
method: fAdmin.method || "POST",
|
|
652
|
+
headers: { "Content-Type": "application/json" },
|
|
653
|
+
body: JSON.stringify({ email: emailValue }),
|
|
654
|
+
});
|
|
655
|
+
let result;
|
|
656
|
+
try {
|
|
657
|
+
result = await response.json();
|
|
658
|
+
} catch {
|
|
659
|
+
result = {};
|
|
660
|
+
}
|
|
661
|
+
if (response.ok && result.success) {
|
|
662
|
+
onActionSuccess?.(
|
|
663
|
+
result.message || "Action completed successfully",
|
|
664
|
+
);
|
|
665
|
+
} else {
|
|
666
|
+
const errorMsg =
|
|
667
|
+
result.error ||
|
|
668
|
+
`Request failed (${response.status})`;
|
|
669
|
+
onActionError?.(errorMsg);
|
|
670
|
+
}
|
|
671
|
+
} catch (err: any) {
|
|
672
|
+
onActionError?.(
|
|
673
|
+
err.message || "Error connecting to server",
|
|
674
|
+
);
|
|
675
|
+
} finally {
|
|
676
|
+
setLoadingFields((prev) => ({
|
|
677
|
+
...prev,
|
|
678
|
+
[f.name!]: false,
|
|
679
|
+
}));
|
|
680
|
+
}
|
|
681
|
+
}}
|
|
682
|
+
//@ts-ignore
|
|
683
|
+
disabled={loadingFields[f.name!] || disabled}
|
|
684
|
+
className="bg-[var(--kyro-primary)] text-white px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
685
|
+
>
|
|
686
|
+
{loadingFields[f.name!] ? "Sending..." : f.label || "Click"}
|
|
687
|
+
</button>
|
|
688
|
+
</div>
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return (
|
|
693
|
+
<div
|
|
694
|
+
key={f.name}
|
|
695
|
+
className={f.type === "button" ? "flex-shrink-0" : "flex-1"}
|
|
696
|
+
style={
|
|
697
|
+
fAdmin?.width ? { width: fAdmin.width, flex: "none" } : {}
|
|
698
|
+
}
|
|
699
|
+
>
|
|
700
|
+
{renderField(f, parentData, onParentChange)}
|
|
84
701
|
</div>
|
|
85
|
-
|
|
86
|
-
)
|
|
702
|
+
);
|
|
703
|
+
})}
|
|
87
704
|
</div>
|
|
88
705
|
);
|
|
89
706
|
}
|
|
90
707
|
|
|
91
708
|
switch (field.type) {
|
|
92
|
-
case "
|
|
93
|
-
|
|
709
|
+
case "tabs": {
|
|
710
|
+
const fieldTabs = (field as any).tabs;
|
|
711
|
+
const currentTab = fieldTabs[activeTab] || fieldTabs[0];
|
|
712
|
+
|
|
94
713
|
return (
|
|
95
|
-
<div
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
714
|
+
<div
|
|
715
|
+
key={field.name || `tabs-${Math.random()}`}
|
|
716
|
+
className="space-y-8"
|
|
717
|
+
>
|
|
718
|
+
<div className="flex items-center gap-1 border-b border-[var(--kyro-border)] mb-6">
|
|
719
|
+
{fieldTabs.map((tab: any, index: number) => (
|
|
720
|
+
<button
|
|
721
|
+
key={index}
|
|
722
|
+
type="button"
|
|
723
|
+
className={`px-6 py-3 text-sm font-bold transition-all border-b-2 -mb-[1px] ${
|
|
724
|
+
activeTab === index
|
|
725
|
+
? "border-[var(--kyro-text-primary)] text-[var(--kyro-text-primary)]"
|
|
726
|
+
: "border-transparent text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
727
|
+
}`}
|
|
728
|
+
onClick={() => setActiveTab(index)}
|
|
729
|
+
>
|
|
730
|
+
{tab.label}
|
|
731
|
+
</button>
|
|
732
|
+
))}
|
|
733
|
+
</div>
|
|
734
|
+
<div className="space-y-6">
|
|
735
|
+
{currentTab?.fields.map((f: Field) =>
|
|
736
|
+
renderField(f, parentData, onParentChange),
|
|
100
737
|
)}
|
|
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>
|
|
738
|
+
</div>
|
|
739
|
+
|
|
740
|
+
{currentTab?.label === "SEO" && (
|
|
741
|
+
<div className="mt-12 pt-8 border-t border-[var(--kyro-border)]">
|
|
742
|
+
<h4 className="text-xs font-bold text-[var(--kyro-text-secondary)] uppercase tracking-[0.2em] mb-6 opacity-50">
|
|
743
|
+
Live Google Preview
|
|
744
|
+
</h4>
|
|
745
|
+
<SeoPreview
|
|
746
|
+
title={formData.metaTitle || formData.title || "Untitled"}
|
|
747
|
+
description={
|
|
748
|
+
formData.metaDescription || "Please enter a description..."
|
|
749
|
+
}
|
|
750
|
+
slug={formData.slug || "your-slug"}
|
|
751
|
+
/>
|
|
752
|
+
</div>
|
|
120
753
|
)}
|
|
121
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
122
754
|
</div>
|
|
123
755
|
);
|
|
756
|
+
}
|
|
757
|
+
case "text":
|
|
758
|
+
case "email":
|
|
759
|
+
const textValue = currentData[field.name!];
|
|
760
|
+
const isKeyHidden = String(textValue).startsWith("••");
|
|
124
761
|
|
|
125
|
-
case "password":
|
|
126
762
|
return (
|
|
127
763
|
<div key={field.name} className="kyro-form-field">
|
|
128
|
-
<label className="kyro-form-label">
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
764
|
+
<label className="kyro-form-label flex items-center justify-between">
|
|
765
|
+
<div className="flex items-center gap-2">
|
|
766
|
+
{field.label || field.name}
|
|
767
|
+
{field.required && (
|
|
768
|
+
<span className="kyro-form-label-required">*</span>
|
|
769
|
+
)}
|
|
770
|
+
</div>
|
|
771
|
+
{(field.admin?.autoGenerate || field.admin?.readOnly) && (
|
|
772
|
+
<button
|
|
773
|
+
type="button"
|
|
774
|
+
onClick={async (e) => {
|
|
775
|
+
e.preventDefault();
|
|
776
|
+
e.stopPropagation();
|
|
777
|
+
|
|
778
|
+
if (field.admin?.autoGenerate === "key") {
|
|
779
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
780
|
+
let suffix = "";
|
|
781
|
+
for (let i = 0; i < 32; i++) {
|
|
782
|
+
suffix +=
|
|
783
|
+
chars[Math.floor(Math.random() * chars.length)];
|
|
784
|
+
}
|
|
785
|
+
onFieldChange(`kyro_${suffix}`);
|
|
786
|
+
} else if (field.admin?.autoGenerate) {
|
|
787
|
+
onFieldChange(
|
|
788
|
+
slugifyText(
|
|
789
|
+
formData[field.admin!.autoGenerate as string] || "",
|
|
790
|
+
),
|
|
791
|
+
);
|
|
792
|
+
} else if (
|
|
793
|
+
field.admin?.readOnly &&
|
|
794
|
+
textValue &&
|
|
795
|
+
!isKeyHidden
|
|
796
|
+
) {
|
|
797
|
+
await navigator.clipboard.writeText(String(textValue));
|
|
798
|
+
const actualKey = textValue;
|
|
799
|
+
onFieldChange(actualKey + "__COPIED__");
|
|
800
|
+
setTimeout(
|
|
801
|
+
() => onFieldChange("••••••••••••••••••••••••••••••"),
|
|
802
|
+
100,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}}
|
|
806
|
+
className="p-1.5 rounded-lg text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:bg-[var(--kyro-surface-accent)] transition-all"
|
|
807
|
+
title={
|
|
808
|
+
field.admin?.autoGenerate === "key"
|
|
809
|
+
? "Generate new key"
|
|
810
|
+
: field.admin?.autoGenerate
|
|
811
|
+
? `Generate from ${field.admin.autoGenerate}`
|
|
812
|
+
: "Copy to clipboard"
|
|
813
|
+
}
|
|
814
|
+
>
|
|
815
|
+
{field.admin?.autoGenerate === "key" ? (
|
|
816
|
+
<svg
|
|
817
|
+
width="14"
|
|
818
|
+
height="14"
|
|
819
|
+
viewBox="0 0 24 24"
|
|
820
|
+
fill="none"
|
|
821
|
+
stroke="currentColor"
|
|
822
|
+
strokeWidth="2"
|
|
823
|
+
>
|
|
824
|
+
<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" />
|
|
825
|
+
</svg>
|
|
826
|
+
) : field.admin?.autoGenerate ? (
|
|
827
|
+
<svg
|
|
828
|
+
width="14"
|
|
829
|
+
height="14"
|
|
830
|
+
viewBox="0 0 24 24"
|
|
831
|
+
fill="none"
|
|
832
|
+
stroke="currentColor"
|
|
833
|
+
strokeWidth="2"
|
|
834
|
+
>
|
|
835
|
+
<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" />
|
|
836
|
+
</svg>
|
|
837
|
+
) : (
|
|
838
|
+
<svg
|
|
839
|
+
width="14"
|
|
840
|
+
height="14"
|
|
841
|
+
viewBox="0 0 24 24"
|
|
842
|
+
fill="none"
|
|
843
|
+
stroke="currentColor"
|
|
844
|
+
strokeWidth="2"
|
|
845
|
+
>
|
|
846
|
+
<rect x="8" y="8" width="12" height="12" rx="2" />
|
|
847
|
+
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
|
|
848
|
+
</svg>
|
|
849
|
+
)}
|
|
850
|
+
</button>
|
|
132
851
|
)}
|
|
133
852
|
</label>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
853
|
+
{field.name === "slug" ? (
|
|
854
|
+
<div className="flex items-center gap-2">
|
|
855
|
+
<div className="relative flex-1">
|
|
856
|
+
<input
|
|
857
|
+
type="text"
|
|
858
|
+
className={`kyro-form-input pr-24 ${isSlugLocked ? "opacity-70 bg-[var(--kyro-bg-secondary)]" : ""}`}
|
|
859
|
+
value={value || ""}
|
|
860
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
861
|
+
disabled={isSlugLocked || disabled}
|
|
862
|
+
/>
|
|
863
|
+
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
|
864
|
+
{!isSlugLocked && (
|
|
865
|
+
<button
|
|
866
|
+
type="button"
|
|
867
|
+
onClick={() =>
|
|
868
|
+
onFieldChange(
|
|
869
|
+
slugifyText(
|
|
870
|
+
formData[field.admin?.autoGenerate || "title"] ||
|
|
871
|
+
"",
|
|
872
|
+
),
|
|
873
|
+
)
|
|
874
|
+
}
|
|
875
|
+
className="p-1 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)]"
|
|
876
|
+
>
|
|
877
|
+
<svg
|
|
878
|
+
width="12"
|
|
879
|
+
height="12"
|
|
880
|
+
viewBox="0 0 24 24"
|
|
881
|
+
fill="none"
|
|
882
|
+
stroke="currentColor"
|
|
883
|
+
strokeWidth="2.5"
|
|
884
|
+
>
|
|
885
|
+
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
|
886
|
+
<path d="M21 3v5h-5" />
|
|
887
|
+
</svg>
|
|
888
|
+
</button>
|
|
889
|
+
)}
|
|
890
|
+
<button
|
|
891
|
+
type="button"
|
|
892
|
+
onClick={() => setIsSlugLocked(!isSlugLocked)}
|
|
893
|
+
className={`p-1.5 rounded ${isSlugLocked ? "text-[var(--kyro-primary)]" : "text-[var(--kyro-text-secondary)]"}`}
|
|
894
|
+
>
|
|
895
|
+
{isSlugLocked ? (
|
|
896
|
+
<svg
|
|
897
|
+
width="12"
|
|
898
|
+
height="12"
|
|
899
|
+
viewBox="0 0 24 24"
|
|
900
|
+
fill="none"
|
|
901
|
+
stroke="currentColor"
|
|
902
|
+
strokeWidth="2.5"
|
|
903
|
+
>
|
|
904
|
+
<rect
|
|
905
|
+
x="3"
|
|
906
|
+
y="11"
|
|
907
|
+
width="18"
|
|
908
|
+
height="11"
|
|
909
|
+
rx="2"
|
|
910
|
+
ry="2"
|
|
911
|
+
/>
|
|
912
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
913
|
+
</svg>
|
|
914
|
+
) : (
|
|
915
|
+
<svg
|
|
916
|
+
width="12"
|
|
917
|
+
height="12"
|
|
918
|
+
viewBox="0 0 24 24"
|
|
919
|
+
fill="none"
|
|
920
|
+
stroke="currentColor"
|
|
921
|
+
strokeWidth="2.5"
|
|
922
|
+
>
|
|
923
|
+
<rect
|
|
924
|
+
x="3"
|
|
925
|
+
y="11"
|
|
926
|
+
width="18"
|
|
927
|
+
height="11"
|
|
928
|
+
rx="2"
|
|
929
|
+
ry="2"
|
|
930
|
+
/>
|
|
931
|
+
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
|
932
|
+
</svg>
|
|
933
|
+
)}
|
|
934
|
+
</button>
|
|
935
|
+
</div>
|
|
936
|
+
</div>
|
|
937
|
+
</div>
|
|
938
|
+
) : (
|
|
939
|
+
<input
|
|
940
|
+
type={(field as any).variant === "url" ? "url" : "text"}
|
|
941
|
+
className="kyro-form-input"
|
|
942
|
+
value={value || ""}
|
|
943
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
944
|
+
disabled={disabled}
|
|
945
|
+
/>
|
|
946
|
+
)}
|
|
947
|
+
{field.name?.toLowerCase().includes("metatitle") && (
|
|
948
|
+
<div className="flex items-center justify-between mt-1 text-[10px] font-bold uppercase tracking-wider">
|
|
949
|
+
<span
|
|
950
|
+
className={
|
|
951
|
+
(value?.length || 0) > 60
|
|
952
|
+
? "text-red-500"
|
|
953
|
+
: (value?.length || 0) >= 40
|
|
954
|
+
? "text-green-500"
|
|
955
|
+
: "text-amber-600"
|
|
956
|
+
}
|
|
957
|
+
>
|
|
958
|
+
{value?.length || 0} / 60 —{" "}
|
|
959
|
+
{(value?.length || 0) > 60
|
|
960
|
+
? "Too Long"
|
|
961
|
+
: (value?.length || 0) >= 40
|
|
962
|
+
? "Ideal"
|
|
963
|
+
: "Short"}
|
|
964
|
+
</span>
|
|
965
|
+
</div>
|
|
966
|
+
)}
|
|
142
967
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
143
968
|
</div>
|
|
144
969
|
);
|
|
@@ -148,25 +973,157 @@ export function AutoForm({
|
|
|
148
973
|
<div key={field.name} className="kyro-form-field">
|
|
149
974
|
<label className="kyro-form-label">
|
|
150
975
|
{field.label || field.name}
|
|
151
|
-
{field.required && (
|
|
152
|
-
<span className="kyro-form-label-required">*</span>
|
|
153
|
-
)}
|
|
154
976
|
</label>
|
|
155
977
|
<textarea
|
|
156
|
-
className=
|
|
978
|
+
className="kyro-form-input kyro-form-textarea"
|
|
157
979
|
value={value || ""}
|
|
158
|
-
onChange={(e) =>
|
|
980
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
159
981
|
disabled={disabled}
|
|
160
|
-
rows={
|
|
161
|
-
placeholder={`Enter ${field.label || field.name}`}
|
|
982
|
+
rows={4}
|
|
162
983
|
/>
|
|
163
|
-
{field.
|
|
164
|
-
<
|
|
984
|
+
{field.name?.toLowerCase().includes("metadescription") && (
|
|
985
|
+
<div className="mt-1 text-[10px] font-bold uppercase tracking-wider">
|
|
986
|
+
<span
|
|
987
|
+
className={
|
|
988
|
+
(value?.length || 0) > 160
|
|
989
|
+
? "text-red-500"
|
|
990
|
+
: (value?.length || 0) >= 120
|
|
991
|
+
? "text-green-500"
|
|
992
|
+
: "text-amber-600"
|
|
993
|
+
}
|
|
994
|
+
>
|
|
995
|
+
{value?.length || 0} / 160 —{" "}
|
|
996
|
+
{(value?.length || 0) > 160
|
|
997
|
+
? "Too Long"
|
|
998
|
+
: (value?.length || 0) >= 120
|
|
999
|
+
? "Ideal"
|
|
1000
|
+
: "Short"}
|
|
1001
|
+
</span>
|
|
1002
|
+
</div>
|
|
165
1003
|
)}
|
|
166
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
167
1004
|
</div>
|
|
168
1005
|
);
|
|
169
1006
|
|
|
1007
|
+
case "richtext":
|
|
1008
|
+
return (field as any).hasBlocks === false ? (
|
|
1009
|
+
<PortableTextField
|
|
1010
|
+
key={field.name}
|
|
1011
|
+
field={field as any}
|
|
1012
|
+
value={value}
|
|
1013
|
+
onChange={(newValue: any) => onFieldChange(newValue)}
|
|
1014
|
+
disabled={disabled}
|
|
1015
|
+
error={error}
|
|
1016
|
+
/>
|
|
1017
|
+
) : (
|
|
1018
|
+
<BlocksField
|
|
1019
|
+
key={field.name}
|
|
1020
|
+
field={field as any}
|
|
1021
|
+
value={value}
|
|
1022
|
+
onChange={(newValue: any) => onFieldChange(newValue)}
|
|
1023
|
+
disabled={disabled}
|
|
1024
|
+
error={error}
|
|
1025
|
+
/>
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
case "group":
|
|
1029
|
+
if ("fields" in field) {
|
|
1030
|
+
const groupData = value || {};
|
|
1031
|
+
return (
|
|
1032
|
+
<div key={field.name} className="kyro-form-group">
|
|
1033
|
+
<h3 className="kyro-form-group-title">
|
|
1034
|
+
{field.label || field.name}
|
|
1035
|
+
</h3>
|
|
1036
|
+
<div className="kyro-form-group-fields">
|
|
1037
|
+
{(field as any).fields.map((f: Field) =>
|
|
1038
|
+
renderField(f, groupData, onFieldChange),
|
|
1039
|
+
)}
|
|
1040
|
+
</div>
|
|
1041
|
+
</div>
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
return null;
|
|
1045
|
+
|
|
1046
|
+
case "array":
|
|
1047
|
+
if ("fields" in field) {
|
|
1048
|
+
const items = Array.isArray(value) ? value : [];
|
|
1049
|
+
const labelField = (field as any).fields?.[0]?.name || "user";
|
|
1050
|
+
const isRelationship =
|
|
1051
|
+
(field as any).fields?.[0]?.type === "relationship";
|
|
1052
|
+
return (
|
|
1053
|
+
<div key={field.name} className="kyro-form-field">
|
|
1054
|
+
<label className="kyro-form-label">
|
|
1055
|
+
{field.label || field.name}
|
|
1056
|
+
</label>
|
|
1057
|
+
{isRelationship ? (
|
|
1058
|
+
<RelationshipField
|
|
1059
|
+
field={{
|
|
1060
|
+
name: labelField,
|
|
1061
|
+
relationTo: (field as any).fields[0].relationTo,
|
|
1062
|
+
hasMany: true,
|
|
1063
|
+
label: (field as any).fields[0].label,
|
|
1064
|
+
}}
|
|
1065
|
+
value={items.map((i: any) => i[labelField]).filter(Boolean)}
|
|
1066
|
+
onChange={(newValue: any) => {
|
|
1067
|
+
const newItems = (newValue || []).map((id: string) => ({
|
|
1068
|
+
[labelField]: id,
|
|
1069
|
+
}));
|
|
1070
|
+
onFieldChange(newItems);
|
|
1071
|
+
}}
|
|
1072
|
+
disabled={disabled}
|
|
1073
|
+
/>
|
|
1074
|
+
) : (
|
|
1075
|
+
<div className="kyro-form-array">
|
|
1076
|
+
{items.map((item: any, index: number) => (
|
|
1077
|
+
<div key={index} className="kyro-form-array-item">
|
|
1078
|
+
<div className="flex justify-between mb-2">
|
|
1079
|
+
<span className="text-xs font-bold opacity-50">
|
|
1080
|
+
Item {index + 1}
|
|
1081
|
+
</span>
|
|
1082
|
+
<button
|
|
1083
|
+
type="button"
|
|
1084
|
+
className="text-red-500"
|
|
1085
|
+
onClick={() =>
|
|
1086
|
+
onFieldChange(items.filter((_, i) => i !== index))
|
|
1087
|
+
}
|
|
1088
|
+
>
|
|
1089
|
+
Remove
|
|
1090
|
+
</button>
|
|
1091
|
+
</div>
|
|
1092
|
+
{(field as any).fields.map((f: Field) =>
|
|
1093
|
+
renderField(f, item, (newItem) => {
|
|
1094
|
+
const newItems = [...items];
|
|
1095
|
+
newItems[index] = newItem;
|
|
1096
|
+
onFieldChange(newItems);
|
|
1097
|
+
}),
|
|
1098
|
+
)}
|
|
1099
|
+
</div>
|
|
1100
|
+
))}
|
|
1101
|
+
<button
|
|
1102
|
+
type="button"
|
|
1103
|
+
className="kyro-btn kyro-btn-secondary kyro-btn-sm"
|
|
1104
|
+
onClick={() => onFieldChange([...items, {}])}
|
|
1105
|
+
>
|
|
1106
|
+
Add Item
|
|
1107
|
+
</button>
|
|
1108
|
+
</div>
|
|
1109
|
+
)}
|
|
1110
|
+
</div>
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
return null;
|
|
1114
|
+
|
|
1115
|
+
case "blocks":
|
|
1116
|
+
return (
|
|
1117
|
+
<BlocksField
|
|
1118
|
+
key={field.name}
|
|
1119
|
+
field={field as any}
|
|
1120
|
+
value={value}
|
|
1121
|
+
onChange={(newValue: any) => onFieldChange(newValue)}
|
|
1122
|
+
disabled={disabled}
|
|
1123
|
+
error={error}
|
|
1124
|
+
/>
|
|
1125
|
+
);
|
|
1126
|
+
|
|
170
1127
|
case "number":
|
|
171
1128
|
return (
|
|
172
1129
|
<div key={field.name} className="kyro-form-field">
|
|
@@ -176,49 +1133,31 @@ export function AutoForm({
|
|
|
176
1133
|
<span className="kyro-form-label-required">*</span>
|
|
177
1134
|
)}
|
|
178
1135
|
</label>
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
onChange={(e) =>
|
|
184
|
-
handleFieldChange(field.name!, parseFloat(e.target.value) || 0)
|
|
185
|
-
}
|
|
1136
|
+
<NumberField
|
|
1137
|
+
field={field as any}
|
|
1138
|
+
value={value}
|
|
1139
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
186
1140
|
disabled={disabled}
|
|
187
|
-
|
|
188
|
-
min={(field as any).min}
|
|
189
|
-
max={(field as any).max}
|
|
190
|
-
step={(field as any).step || "any"}
|
|
1141
|
+
error={error}
|
|
191
1142
|
/>
|
|
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
1143
|
</div>
|
|
197
1144
|
);
|
|
198
1145
|
|
|
199
|
-
case "checkbox":
|
|
200
|
-
return (
|
|
201
|
-
<div key={field.name} className="kyro-form-field">
|
|
202
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
/>
|
|
211
|
-
<span className="kyro-form-checkbox-label">
|
|
212
|
-
{field.label || field.name}
|
|
213
|
-
</span>
|
|
214
|
-
</label>
|
|
215
|
-
{field.admin?.description && (
|
|
216
|
-
<p className="kyro-form-help">{field.admin.description}</p>
|
|
217
|
-
)}
|
|
1146
|
+
case "checkbox":
|
|
1147
|
+
return (
|
|
1148
|
+
<div key={field.name} className="kyro-form-field">
|
|
1149
|
+
<CheckboxField
|
|
1150
|
+
field={field as any}
|
|
1151
|
+
value={value}
|
|
1152
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
1153
|
+
disabled={disabled}
|
|
1154
|
+
error={error}
|
|
1155
|
+
/>
|
|
1156
|
+
{error && <p className="kyro-form-error">{error}</p>}
|
|
218
1157
|
</div>
|
|
219
1158
|
);
|
|
220
1159
|
|
|
221
|
-
case "
|
|
1160
|
+
case "select":
|
|
222
1161
|
return (
|
|
223
1162
|
<div key={field.name} className="kyro-form-field">
|
|
224
1163
|
<label className="kyro-form-label">
|
|
@@ -227,23 +1166,18 @@ export function AutoForm({
|
|
|
227
1166
|
<span className="kyro-form-label-required">*</span>
|
|
228
1167
|
)}
|
|
229
1168
|
</label>
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
onChange={(e) =>
|
|
235
|
-
handleFieldChange(
|
|
236
|
-
field.name!,
|
|
237
|
-
e.target.value ? new Date(e.target.value) : null,
|
|
238
|
-
)
|
|
239
|
-
}
|
|
1169
|
+
<SelectField
|
|
1170
|
+
field={field as any}
|
|
1171
|
+
value={value}
|
|
1172
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
240
1173
|
disabled={disabled}
|
|
1174
|
+
error={error}
|
|
241
1175
|
/>
|
|
242
1176
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
243
1177
|
</div>
|
|
244
1178
|
);
|
|
245
1179
|
|
|
246
|
-
case "
|
|
1180
|
+
case "date":
|
|
247
1181
|
return (
|
|
248
1182
|
<div key={field.name} className="kyro-form-field">
|
|
249
1183
|
<label className="kyro-form-label">
|
|
@@ -252,22 +1186,38 @@ export function AutoForm({
|
|
|
252
1186
|
<span className="kyro-form-label-required">*</span>
|
|
253
1187
|
)}
|
|
254
1188
|
</label>
|
|
255
|
-
<
|
|
256
|
-
|
|
1189
|
+
<DateField
|
|
1190
|
+
field={field as any}
|
|
1191
|
+
value={value}
|
|
1192
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
1193
|
+
disabled={disabled}
|
|
1194
|
+
error={error}
|
|
1195
|
+
/>
|
|
1196
|
+
{error && <p className="kyro-form-error">{error}</p>}
|
|
1197
|
+
</div>
|
|
1198
|
+
);
|
|
1199
|
+
|
|
1200
|
+
case "password":
|
|
1201
|
+
return (
|
|
1202
|
+
<div key={field.name} className="kyro-form-field">
|
|
1203
|
+
<label className="kyro-form-label flex items-center justify-between">
|
|
1204
|
+
<div className="flex items-center gap-2">
|
|
1205
|
+
{field.label || field.name}
|
|
1206
|
+
{field.required && (
|
|
1207
|
+
<span className="kyro-form-label-required">*</span>
|
|
1208
|
+
)}
|
|
1209
|
+
</div>
|
|
1210
|
+
</label>
|
|
1211
|
+
<input
|
|
1212
|
+
type="password"
|
|
1213
|
+
className="kyro-form-input"
|
|
257
1214
|
value={value || ""}
|
|
258
|
-
onChange={(e) =>
|
|
1215
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
259
1216
|
disabled={disabled}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
)}
|
|
1217
|
+
placeholder={
|
|
1218
|
+
field.admin?.placeholder || `Enter ${field.label || field.name}`
|
|
1219
|
+
}
|
|
1220
|
+
/>
|
|
271
1221
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
272
1222
|
</div>
|
|
273
1223
|
);
|
|
@@ -282,19 +1232,18 @@ export function AutoForm({
|
|
|
282
1232
|
)}
|
|
283
1233
|
</label>
|
|
284
1234
|
<div className="kyro-form-radio-group">
|
|
285
|
-
{(field as any).options
|
|
286
|
-
<label key={opt.value
|
|
1235
|
+
{((field as any).options || []).map((opt: any) => (
|
|
1236
|
+
<label key={opt.value} className="kyro-form-radio-label">
|
|
287
1237
|
<input
|
|
288
1238
|
type="radio"
|
|
289
1239
|
name={field.name}
|
|
290
|
-
value={opt.value
|
|
291
|
-
checked={value ===
|
|
292
|
-
onChange={(
|
|
293
|
-
handleFieldChange(field.name!, e.target.value)
|
|
294
|
-
}
|
|
1240
|
+
value={opt.value}
|
|
1241
|
+
checked={value === opt.value}
|
|
1242
|
+
onChange={() => onFieldChange(opt.value)}
|
|
295
1243
|
disabled={disabled}
|
|
1244
|
+
className="kyro-form-radio"
|
|
296
1245
|
/>
|
|
297
|
-
<span>{opt.label || opt}</span>
|
|
1246
|
+
<span>{opt.label || opt.value}</span>
|
|
298
1247
|
</label>
|
|
299
1248
|
))}
|
|
300
1249
|
</div>
|
|
@@ -305,635 +1254,1014 @@ export function AutoForm({
|
|
|
305
1254
|
case "color":
|
|
306
1255
|
return (
|
|
307
1256
|
<div key={field.name} className="kyro-form-field">
|
|
308
|
-
<label className="kyro-form-label">
|
|
1257
|
+
<label className="kyro-form-label flex items-center gap-2">
|
|
309
1258
|
{field.label || field.name}
|
|
310
1259
|
{field.required && (
|
|
311
1260
|
<span className="kyro-form-label-required">*</span>
|
|
312
1261
|
)}
|
|
1262
|
+
{value && (
|
|
1263
|
+
<span
|
|
1264
|
+
className="w-5 h-5 rounded border border-[var(--kyro-border)] shrink-0"
|
|
1265
|
+
style={{ backgroundColor: value }}
|
|
1266
|
+
/>
|
|
1267
|
+
)}
|
|
313
1268
|
</label>
|
|
314
|
-
<div className="
|
|
1269
|
+
<div className="flex items-center gap-3">
|
|
315
1270
|
<input
|
|
316
1271
|
type="color"
|
|
317
|
-
className={`kyro-form-color ${error ? "kyro-form-input-error" : ""}`}
|
|
318
1272
|
value={value || "#000000"}
|
|
319
|
-
onChange={(e) =>
|
|
1273
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
320
1274
|
disabled={disabled}
|
|
1275
|
+
className="kyro-form-input h-10 w-14 p-1 cursor-pointer"
|
|
1276
|
+
/>
|
|
1277
|
+
<input
|
|
1278
|
+
type="text"
|
|
1279
|
+
className="kyro-form-input font-mono uppercase"
|
|
1280
|
+
value={value || ""}
|
|
1281
|
+
onChange={(e) => onFieldChange(e.target.value)}
|
|
1282
|
+
disabled={disabled}
|
|
1283
|
+
placeholder="#000000"
|
|
321
1284
|
/>
|
|
322
|
-
<span className="kyro-form-color-value">
|
|
323
|
-
{value || "#000000"}
|
|
324
|
-
</span>
|
|
325
1285
|
</div>
|
|
326
1286
|
{error && <p className="kyro-form-error">{error}</p>}
|
|
327
1287
|
</div>
|
|
328
1288
|
);
|
|
329
1289
|
|
|
330
|
-
case "
|
|
1290
|
+
case "markdown":
|
|
1291
|
+
return (
|
|
1292
|
+
<MarkdownField
|
|
1293
|
+
key={field.name}
|
|
1294
|
+
field={field as any}
|
|
1295
|
+
value={value || ""}
|
|
1296
|
+
onChange={(val) => onFieldChange(val)}
|
|
1297
|
+
disabled={disabled}
|
|
1298
|
+
/>
|
|
1299
|
+
);
|
|
1300
|
+
|
|
1301
|
+
case "button": {
|
|
1302
|
+
const isLoading = loadingFields[field.name!];
|
|
331
1303
|
return (
|
|
332
1304
|
<div key={field.name} className="kyro-form-field">
|
|
333
|
-
<
|
|
334
|
-
|
|
335
|
-
{
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
1305
|
+
<button
|
|
1306
|
+
type="button"
|
|
1307
|
+
disabled={isLoading || disabled}
|
|
1308
|
+
onClick={async () => {
|
|
1309
|
+
const action = field.admin?.action || (field as any).action;
|
|
1310
|
+
const method =
|
|
1311
|
+
field.admin?.method || (field as any).method || "POST";
|
|
1312
|
+
if (action) {
|
|
1313
|
+
setLoadingFields((prev) => ({
|
|
1314
|
+
...prev,
|
|
1315
|
+
[field.name!]: true,
|
|
1316
|
+
}));
|
|
1317
|
+
try {
|
|
1318
|
+
const response = await fetch(action, {
|
|
1319
|
+
method,
|
|
1320
|
+
headers: { "Content-Type": "application/json" },
|
|
1321
|
+
body: JSON.stringify(formData),
|
|
1322
|
+
});
|
|
1323
|
+
const result = await response.json();
|
|
1324
|
+
if (response.ok) {
|
|
1325
|
+
// handle result
|
|
1326
|
+
} else {
|
|
1327
|
+
// handle error
|
|
1328
|
+
}
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
console.error("Error executing action:", err);
|
|
1331
|
+
} finally {
|
|
1332
|
+
setLoadingFields((prev) => ({
|
|
1333
|
+
...prev,
|
|
1334
|
+
[field.name!]: false,
|
|
1335
|
+
}));
|
|
1336
|
+
}
|
|
351
1337
|
}
|
|
352
1338
|
}}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1339
|
+
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" : ""}`}
|
|
1340
|
+
>
|
|
1341
|
+
{isLoading && (
|
|
1342
|
+
<svg
|
|
1343
|
+
className="animate-spin h-3 w-3 text-white"
|
|
1344
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1345
|
+
fill="none"
|
|
1346
|
+
viewBox="0 0 24 24"
|
|
1347
|
+
>
|
|
1348
|
+
<circle
|
|
1349
|
+
className="opacity-25"
|
|
1350
|
+
cx="12"
|
|
1351
|
+
cy="12"
|
|
1352
|
+
r="10"
|
|
1353
|
+
stroke="currentColor"
|
|
1354
|
+
strokeWidth="4"
|
|
1355
|
+
></circle>
|
|
1356
|
+
<path
|
|
1357
|
+
className="opacity-75"
|
|
1358
|
+
fill="currentColor"
|
|
1359
|
+
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"
|
|
1360
|
+
></path>
|
|
1361
|
+
</svg>
|
|
1362
|
+
)}
|
|
1363
|
+
{isLoading ? "Processing..." : field.label || "Click"}
|
|
1364
|
+
</button>
|
|
361
1365
|
</div>
|
|
362
1366
|
);
|
|
1367
|
+
}
|
|
363
1368
|
|
|
364
|
-
case "
|
|
1369
|
+
case "relationship":
|
|
365
1370
|
return (
|
|
366
|
-
<
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
className={`kyro-form-input kyro-form-textarea ${error ? "kyro-form-input-error" : ""}`}
|
|
375
|
-
value={value || ""}
|
|
376
|
-
onChange={(e) => handleFieldChange(field.name!, e.target.value)}
|
|
377
|
-
disabled={disabled}
|
|
378
|
-
rows={8}
|
|
379
|
-
placeholder="Enter markdown content..."
|
|
380
|
-
/>
|
|
381
|
-
{field.admin?.description && !error && (
|
|
382
|
-
<p className="kyro-form-help">{field.admin.description}</p>
|
|
383
|
-
)}
|
|
384
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
385
|
-
</div>
|
|
1371
|
+
<RelationshipField
|
|
1372
|
+
key={field.name}
|
|
1373
|
+
field={field as any}
|
|
1374
|
+
value={value}
|
|
1375
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
1376
|
+
disabled={disabled}
|
|
1377
|
+
error={error}
|
|
1378
|
+
/>
|
|
386
1379
|
);
|
|
387
1380
|
|
|
388
1381
|
case "code":
|
|
389
1382
|
return (
|
|
390
|
-
<
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
className={`kyro-form-input kyro-form-textarea kyro-form-code ${error ? "kyro-form-input-error" : ""}`}
|
|
399
|
-
value={value || ""}
|
|
400
|
-
onChange={(e) => handleFieldChange(field.name!, e.target.value)}
|
|
401
|
-
disabled={disabled}
|
|
402
|
-
rows={8}
|
|
403
|
-
placeholder="Enter code..."
|
|
404
|
-
/>
|
|
405
|
-
{field.admin?.description && !error && (
|
|
406
|
-
<p className="kyro-form-help">{field.admin.description}</p>
|
|
407
|
-
)}
|
|
408
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
409
|
-
</div>
|
|
1383
|
+
<CodeField
|
|
1384
|
+
key={field.name}
|
|
1385
|
+
field={field as any}
|
|
1386
|
+
value={value || ""}
|
|
1387
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
1388
|
+
disabled={disabled}
|
|
1389
|
+
error={error}
|
|
1390
|
+
/>
|
|
410
1391
|
);
|
|
411
1392
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
);
|
|
427
|
-
}
|
|
1393
|
+
// @ts-ignore - 'image' is supported but not in the standard union yet
|
|
1394
|
+
case "image":
|
|
1395
|
+
case "upload":
|
|
1396
|
+
return (
|
|
1397
|
+
<UploadField
|
|
1398
|
+
key={field.name}
|
|
1399
|
+
field={field as any}
|
|
1400
|
+
value={value}
|
|
1401
|
+
onChange={(newValue) => onFieldChange(newValue)}
|
|
1402
|
+
disabled={disabled}
|
|
1403
|
+
/>
|
|
1404
|
+
);
|
|
1405
|
+
|
|
1406
|
+
default:
|
|
428
1407
|
return null;
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
429
1410
|
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1411
|
+
const renderHeader = () => {
|
|
1412
|
+
const docTitle = formData.title || formData.name || "Untitled";
|
|
1413
|
+
const status = formData.status || "draft";
|
|
1414
|
+
const isNew = !formData.id;
|
|
1415
|
+
const lastModified = formData.updatedAt
|
|
1416
|
+
? new Date(formData.updatedAt).toLocaleString()
|
|
1417
|
+
: "Just now";
|
|
1418
|
+
const createdAt = formData.createdAt
|
|
1419
|
+
? new Date(formData.createdAt).toLocaleString()
|
|
1420
|
+
: "Just now";
|
|
1421
|
+
|
|
1422
|
+
return (
|
|
1423
|
+
<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">
|
|
1424
|
+
<div className="flex flex-col gap-1">
|
|
1425
|
+
<div className="flex items-center gap-4">
|
|
1426
|
+
<a
|
|
1427
|
+
href={`/${collectionSlug}`}
|
|
1428
|
+
className="p-2 border border-[var(--kyro-border)] rounded-xl hover:bg-[var(--kyro-bg-secondary)] transition-colors"
|
|
1429
|
+
>
|
|
1430
|
+
<svg
|
|
1431
|
+
className="w-4 h-4"
|
|
1432
|
+
fill="none"
|
|
1433
|
+
stroke="currentColor"
|
|
1434
|
+
viewBox="0 0 24 24"
|
|
1435
|
+
>
|
|
1436
|
+
<path
|
|
1437
|
+
strokeLinecap="round"
|
|
1438
|
+
strokeLinejoin="round"
|
|
1439
|
+
strokeWidth="2.5"
|
|
1440
|
+
d="M15 19l-7-7 7-7"
|
|
1441
|
+
/>
|
|
1442
|
+
</svg>
|
|
1443
|
+
</a>
|
|
1444
|
+
<h1 className="text-xl font-bold tracking-tighter">{docTitle}</h1>
|
|
1445
|
+
</div>
|
|
1446
|
+
<div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
|
|
1447
|
+
<span className="flex items-center gap-1.5 capitalize">
|
|
1448
|
+
<span
|
|
1449
|
+
className={`h-1.5 w-1.5 rounded-full ${status === "published" && !hasUnsavedChanges ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-warning)]"}`}
|
|
1450
|
+
/>
|
|
1451
|
+
{hasUnsavedChanges ? "Draft" : status}
|
|
1452
|
+
</span>
|
|
1453
|
+
{autoSaveStatus === "saving" && (
|
|
1454
|
+
<span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
|
|
1455
|
+
<svg
|
|
1456
|
+
className="animate-spin h-3 w-3"
|
|
1457
|
+
viewBox="0 0 24 24"
|
|
1458
|
+
fill="none"
|
|
1459
|
+
>
|
|
1460
|
+
<circle
|
|
1461
|
+
className="opacity-25"
|
|
1462
|
+
cx="12"
|
|
1463
|
+
cy="12"
|
|
1464
|
+
r="10"
|
|
1465
|
+
stroke="currentColor"
|
|
1466
|
+
strokeWidth="4"
|
|
1467
|
+
/>
|
|
1468
|
+
<path
|
|
1469
|
+
className="opacity-75"
|
|
1470
|
+
fill="currentColor"
|
|
1471
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
1472
|
+
/>
|
|
1473
|
+
</svg>
|
|
1474
|
+
Saving...
|
|
1475
|
+
</span>
|
|
1476
|
+
)}
|
|
1477
|
+
{autoSaveStatus === "saved" && (
|
|
1478
|
+
<span className="text-[var(--kyro-success)] flex items-center gap-1">
|
|
1479
|
+
<svg
|
|
1480
|
+
width="12"
|
|
1481
|
+
height="12"
|
|
1482
|
+
viewBox="0 0 24 24"
|
|
1483
|
+
fill="none"
|
|
1484
|
+
stroke="currentColor"
|
|
1485
|
+
strokeWidth="3"
|
|
1486
|
+
>
|
|
1487
|
+
<path d="M20 6L9 17l-5-5" />
|
|
1488
|
+
</svg>
|
|
1489
|
+
Saved
|
|
1490
|
+
</span>
|
|
1491
|
+
)}
|
|
1492
|
+
{autoSaveStatus === "error" && (
|
|
1493
|
+
<span className="text-[var(--kyro-danger)]">Save failed</span>
|
|
1494
|
+
)}
|
|
1495
|
+
{hasUnsavedChanges && autoSaveStatus !== "saving" && (
|
|
1496
|
+
<>
|
|
1497
|
+
<span className="opacity-30">—</span>
|
|
505
1498
|
<button
|
|
506
1499
|
type="button"
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
}
|
|
514
|
-
});
|
|
515
|
-
handleFieldChange(field.name!, [...items, newItem]);
|
|
516
|
-
}}
|
|
517
|
-
disabled={disabled}
|
|
1500
|
+
onClick={() => setFormData(lastSavedData)}
|
|
1501
|
+
className="text-[var(--kyro-primary)] hover:underline"
|
|
518
1502
|
>
|
|
519
|
-
|
|
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 5v14M5 12h14" />
|
|
528
|
-
</svg>
|
|
529
|
-
Add {labels.singular}
|
|
1503
|
+
Revert changes
|
|
530
1504
|
</button>
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
1505
|
+
</>
|
|
1506
|
+
)}
|
|
1507
|
+
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
1508
|
+
Modified {lastModified}
|
|
1509
|
+
</span>
|
|
1510
|
+
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
1511
|
+
Created {createdAt}
|
|
1512
|
+
</span>
|
|
1513
|
+
</div>
|
|
1514
|
+
</div>
|
|
536
1515
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
1516
|
+
<div className="flex items-center gap-6">
|
|
1517
|
+
<div className="flex items-center gap-1 bg-[var(--kyro-bg-secondary)] p-1 rounded-xl border border-[var(--kyro-border)]">
|
|
1518
|
+
{["edit", "version", "api"].map((v) => (
|
|
1519
|
+
<button
|
|
1520
|
+
key={v}
|
|
1521
|
+
type="button"
|
|
1522
|
+
onClick={() => setView(v as any)}
|
|
1523
|
+
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"}`}
|
|
1524
|
+
>
|
|
1525
|
+
{v.toUpperCase()}
|
|
1526
|
+
</button>
|
|
1527
|
+
))}
|
|
1528
|
+
</div>
|
|
1529
|
+
|
|
1530
|
+
<div className="h-8 w-px bg-[var(--kyro-border)] mx-2" />
|
|
1531
|
+
|
|
1532
|
+
<div className="flex items-center gap-3">
|
|
1533
|
+
<button
|
|
1534
|
+
type="button"
|
|
1535
|
+
onClick={() => setShowPreview(!showPreview)}
|
|
1536
|
+
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)]"}`}
|
|
1537
|
+
title="Live Preview"
|
|
1538
|
+
>
|
|
1539
|
+
<svg
|
|
1540
|
+
width="20"
|
|
1541
|
+
height="20"
|
|
1542
|
+
viewBox="0 0 24 24"
|
|
1543
|
+
fill="none"
|
|
1544
|
+
stroke="currentColor"
|
|
1545
|
+
strokeWidth="2"
|
|
1546
|
+
>
|
|
1547
|
+
<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" />
|
|
1548
|
+
</svg>
|
|
1549
|
+
{showPreview && (
|
|
1550
|
+
<span className="text-[10px] font-black uppercase tracking-widest pr-1">
|
|
1551
|
+
Active
|
|
1552
|
+
</span>
|
|
1553
|
+
)}
|
|
1554
|
+
</button>
|
|
1555
|
+
<button
|
|
1556
|
+
type="button"
|
|
1557
|
+
onClick={() => {
|
|
1558
|
+
window.dispatchEvent(new CustomEvent("toggle-sidebar"));
|
|
1559
|
+
}}
|
|
1560
|
+
className={`p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
|
|
1561
|
+
title="Toggle Sidebar"
|
|
1562
|
+
>
|
|
1563
|
+
<svg
|
|
1564
|
+
width="20"
|
|
1565
|
+
height="20"
|
|
1566
|
+
viewBox="0 0 24 24"
|
|
1567
|
+
fill="none"
|
|
1568
|
+
stroke="currentColor"
|
|
1569
|
+
strokeWidth="2"
|
|
1570
|
+
>
|
|
1571
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
1572
|
+
<line x1="9" y1="3" x2="9" y2="21" />
|
|
1573
|
+
</svg>
|
|
1574
|
+
</button>
|
|
1575
|
+
|
|
1576
|
+
<button
|
|
1577
|
+
id="btn-save"
|
|
1578
|
+
type="button"
|
|
1579
|
+
onClick={async () => {
|
|
1580
|
+
const hiddenInput = document.getElementById(
|
|
1581
|
+
"form-data",
|
|
1582
|
+
) as HTMLInputElement;
|
|
1583
|
+
if (!hiddenInput || !hiddenInput.value) return;
|
|
1584
|
+
|
|
1585
|
+
const btn = document.getElementById(
|
|
1586
|
+
"btn-save",
|
|
1587
|
+
) as HTMLButtonElement;
|
|
1588
|
+
const originalText = btn?.textContent || "";
|
|
1589
|
+
if (btn) {
|
|
1590
|
+
btn.textContent = "Saving...";
|
|
1591
|
+
btn.setAttribute("disabled", "true");
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
try {
|
|
1595
|
+
const data = JSON.parse(hiddenInput.value);
|
|
1596
|
+
const url = isNew
|
|
1597
|
+
? `/api/${collectionSlug}`
|
|
1598
|
+
: `/api/${collectionSlug}/${formData.id}`;
|
|
1599
|
+
const method = isNew ? "POST" : "PATCH";
|
|
1600
|
+
|
|
1601
|
+
const response = await fetch(url, {
|
|
1602
|
+
method,
|
|
1603
|
+
credentials: "include",
|
|
1604
|
+
headers: { "Content-Type": "application/json" },
|
|
1605
|
+
body: JSON.stringify(data),
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
if (response.ok) {
|
|
1609
|
+
const result = await response.json();
|
|
1610
|
+
setLastSavedData(result.data || formData);
|
|
1611
|
+
lastAutoSaveTimeRef.current = Date.now();
|
|
1612
|
+
setAutoSaveStatus("saved");
|
|
1613
|
+
fetchVersions();
|
|
1614
|
+
setTimeout(() => setAutoSaveStatus("idle"), 2000);
|
|
1615
|
+
onActionSuccess?.(
|
|
1616
|
+
isNew ? "Document created successfully" : "Changes saved",
|
|
1617
|
+
);
|
|
1618
|
+
if (isNew) {
|
|
1619
|
+
setTimeout(() => {
|
|
1620
|
+
window.location.href = `/${collectionSlug}`;
|
|
1621
|
+
}, 800);
|
|
1622
|
+
}
|
|
1623
|
+
} else {
|
|
1624
|
+
const error = await response.json();
|
|
1625
|
+
setAlertModal({
|
|
1626
|
+
open: true,
|
|
1627
|
+
title: "Error",
|
|
1628
|
+
message: error.error || "Failed to save",
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
} catch (err) {
|
|
1632
|
+
setAlertModal({
|
|
1633
|
+
open: true,
|
|
1634
|
+
title: "Error",
|
|
1635
|
+
message: "Failed to save document",
|
|
1636
|
+
});
|
|
1637
|
+
} finally {
|
|
1638
|
+
if (btn) {
|
|
1639
|
+
btn.textContent = originalText;
|
|
1640
|
+
btn.removeAttribute("disabled");
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}}
|
|
1644
|
+
className="kyro-btn kyro-btn-primary px-6 py-2.5 text-xs rounded-xl shadow-lg transition-all"
|
|
1645
|
+
>
|
|
1646
|
+
{isNew ? "Create" : hasUnsavedChanges ? "Save Draft" : "Saved"}
|
|
1647
|
+
</button>
|
|
1648
|
+
|
|
1649
|
+
{!isNew && status === "draft" && (
|
|
1650
|
+
<button
|
|
1651
|
+
id="btn-publish"
|
|
1652
|
+
type="button"
|
|
1653
|
+
onClick={async () => {
|
|
1654
|
+
const btn = document.getElementById(
|
|
1655
|
+
"btn-publish",
|
|
1656
|
+
) as HTMLButtonElement;
|
|
1657
|
+
const originalText = btn?.textContent || "";
|
|
1658
|
+
if (btn) {
|
|
1659
|
+
btn.textContent = "Publishing...";
|
|
1660
|
+
btn.setAttribute("disabled", "true");
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
try {
|
|
1664
|
+
const response = await fetch(
|
|
1665
|
+
`/api/${collectionSlug}/${formData.id}/publish`,
|
|
1666
|
+
{
|
|
1667
|
+
method: "POST",
|
|
1668
|
+
credentials: "include",
|
|
1669
|
+
},
|
|
1670
|
+
);
|
|
1671
|
+
|
|
1672
|
+
if (response.ok) {
|
|
1673
|
+
onActionSuccess?.("Published successfully");
|
|
1674
|
+
location.reload();
|
|
1675
|
+
} else {
|
|
1676
|
+
const error = await response.json();
|
|
1677
|
+
setAlertModal({
|
|
1678
|
+
open: true,
|
|
1679
|
+
title: "Error",
|
|
1680
|
+
message: error.error || "Failed to publish",
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
setAlertModal({
|
|
1685
|
+
open: true,
|
|
1686
|
+
title: "Error",
|
|
1687
|
+
message: "Failed to publish",
|
|
1688
|
+
});
|
|
1689
|
+
} finally {
|
|
1690
|
+
if (btn) {
|
|
1691
|
+
btn.textContent = originalText;
|
|
1692
|
+
btn.removeAttribute("disabled");
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}}
|
|
1696
|
+
className="px-6 py-2.5 text-xs font-bold rounded-xl border-2 border-[var(--kyro-border)] text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] hover:bg-[var(--kyro-primary)] hover:text-white transition-all"
|
|
1697
|
+
>
|
|
1698
|
+
Publish
|
|
1699
|
+
</button>
|
|
1700
|
+
)}
|
|
1701
|
+
|
|
1702
|
+
<div ref={menuRef} className="relative">
|
|
1703
|
+
<button
|
|
1704
|
+
type="button"
|
|
1705
|
+
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
|
1706
|
+
className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
|
|
1707
|
+
>
|
|
1708
|
+
<svg
|
|
1709
|
+
width="20"
|
|
1710
|
+
height="20"
|
|
1711
|
+
viewBox="0 0 24 24"
|
|
1712
|
+
fill="none"
|
|
1713
|
+
stroke="currentColor"
|
|
1714
|
+
strokeWidth="3"
|
|
1715
|
+
>
|
|
1716
|
+
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
|
1717
|
+
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
|
|
1718
|
+
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
|
|
1719
|
+
</svg>
|
|
1720
|
+
</button>
|
|
1721
|
+
{isMenuOpen && (
|
|
1722
|
+
<div className="absolute right-0 mt-2 w-48 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50 overflow-hidden">
|
|
1723
|
+
<button
|
|
1724
|
+
type="button"
|
|
1725
|
+
onClick={() => {
|
|
1726
|
+
handleCreateNew();
|
|
1727
|
+
setIsMenuOpen(false);
|
|
1728
|
+
}}
|
|
1729
|
+
className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
|
|
1730
|
+
>
|
|
1731
|
+
<svg
|
|
1732
|
+
width="16"
|
|
1733
|
+
height="16"
|
|
1734
|
+
viewBox="0 0 24 24"
|
|
1735
|
+
fill="none"
|
|
1736
|
+
stroke="currentColor"
|
|
1737
|
+
strokeWidth="2"
|
|
1738
|
+
>
|
|
1739
|
+
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
1740
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
1741
|
+
</svg>
|
|
1742
|
+
Create New
|
|
1743
|
+
</button>
|
|
1744
|
+
{!isNew && (
|
|
1745
|
+
<>
|
|
1746
|
+
<button
|
|
1747
|
+
type="button"
|
|
1748
|
+
onClick={() => {
|
|
1749
|
+
handleDuplicate();
|
|
1750
|
+
setIsMenuOpen(false);
|
|
1751
|
+
}}
|
|
1752
|
+
className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
|
|
1753
|
+
>
|
|
1754
|
+
<svg
|
|
1755
|
+
width="16"
|
|
1756
|
+
height="16"
|
|
1757
|
+
viewBox="0 0 24 24"
|
|
1758
|
+
fill="none"
|
|
1759
|
+
stroke="currentColor"
|
|
1760
|
+
strokeWidth="2"
|
|
1761
|
+
>
|
|
1762
|
+
<rect
|
|
1763
|
+
x="9"
|
|
1764
|
+
y="9"
|
|
1765
|
+
width="13"
|
|
1766
|
+
height="13"
|
|
1767
|
+
rx="2"
|
|
1768
|
+
ry="2"
|
|
1769
|
+
></rect>
|
|
1770
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
1771
|
+
</svg>
|
|
1772
|
+
Duplicate
|
|
1773
|
+
</button>
|
|
1774
|
+
{status === "published" && (
|
|
1775
|
+
<button
|
|
1776
|
+
type="button"
|
|
1777
|
+
onClick={() => {
|
|
1778
|
+
handleUnpublish();
|
|
1779
|
+
setIsMenuOpen(false);
|
|
1780
|
+
}}
|
|
1781
|
+
className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
|
|
1782
|
+
>
|
|
1783
|
+
<svg
|
|
1784
|
+
width="16"
|
|
1785
|
+
height="16"
|
|
1786
|
+
viewBox="0 0 24 24"
|
|
1787
|
+
fill="none"
|
|
1788
|
+
stroke="currentColor"
|
|
1789
|
+
strokeWidth="2"
|
|
600
1790
|
>
|
|
601
|
-
<
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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) => (
|
|
1791
|
+
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
|
|
1792
|
+
<line x1="1" y1="1" x2="23" y2="23"></line>
|
|
1793
|
+
</svg>
|
|
1794
|
+
Unpublish
|
|
1795
|
+
</button>
|
|
1796
|
+
)}
|
|
1797
|
+
<div className="h-px bg-[var(--kyro-border)]" />
|
|
652
1798
|
<button
|
|
653
|
-
key={block.slug}
|
|
654
1799
|
type="button"
|
|
655
|
-
className="kyro-btn kyro-btn-secondary kyro-btn-sm"
|
|
656
1800
|
onClick={() => {
|
|
657
|
-
|
|
658
|
-
|
|
1801
|
+
handleDelete();
|
|
1802
|
+
setIsMenuOpen(false);
|
|
659
1803
|
}}
|
|
660
|
-
|
|
1804
|
+
className="w-full px-4 py-2.5 text-left text-xs font-medium text-red-600 hover:bg-red-50 flex items-center gap-3 transition-colors"
|
|
661
1805
|
>
|
|
662
|
-
|
|
1806
|
+
<svg
|
|
1807
|
+
width="16"
|
|
1808
|
+
height="16"
|
|
1809
|
+
viewBox="0 0 24 24"
|
|
1810
|
+
fill="none"
|
|
1811
|
+
stroke="currentColor"
|
|
1812
|
+
strokeWidth="2"
|
|
1813
|
+
>
|
|
1814
|
+
<polyline points="3 6 5 6 21 6"></polyline>
|
|
1815
|
+
<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"></path>
|
|
1816
|
+
</svg>
|
|
1817
|
+
Delete
|
|
663
1818
|
</button>
|
|
664
|
-
|
|
665
|
-
|
|
1819
|
+
</>
|
|
1820
|
+
)}
|
|
666
1821
|
</div>
|
|
667
|
-
</div>
|
|
668
|
-
</div>
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
return null;
|
|
672
|
-
|
|
673
|
-
case "relationship":
|
|
674
|
-
return (
|
|
675
|
-
<RelationshipField
|
|
676
|
-
key={field.name}
|
|
677
|
-
field={field as any}
|
|
678
|
-
value={value}
|
|
679
|
-
onChange={(newValue) => handleFieldChange(field.name!, newValue)}
|
|
680
|
-
disabled={disabled}
|
|
681
|
-
error={error}
|
|
682
|
-
/>
|
|
683
|
-
);
|
|
684
|
-
|
|
685
|
-
case "upload":
|
|
686
|
-
return (
|
|
687
|
-
<UploadField
|
|
688
|
-
key={field.name}
|
|
689
|
-
field={field as any}
|
|
690
|
-
value={value}
|
|
691
|
-
onChange={(newValue) => handleFieldChange(field.name!, newValue)}
|
|
692
|
-
disabled={disabled}
|
|
693
|
-
error={error}
|
|
694
|
-
/>
|
|
695
|
-
);
|
|
696
|
-
|
|
697
|
-
case "richtext":
|
|
698
|
-
return (
|
|
699
|
-
<div key={field.name} className="kyro-form-field">
|
|
700
|
-
<label className="kyro-form-label">
|
|
701
|
-
{field.label || field.name}
|
|
702
|
-
{field.required && (
|
|
703
|
-
<span className="kyro-form-label-required">*</span>
|
|
704
1822
|
)}
|
|
705
|
-
</
|
|
706
|
-
<textarea
|
|
707
|
-
className={`kyro-form-input kyro-form-textarea kyro-form-richtext ${error ? "kyro-form-input-error" : ""}`}
|
|
708
|
-
value={value || ""}
|
|
709
|
-
onChange={(e) => handleFieldChange(field.name!, e.target.value)}
|
|
710
|
-
disabled={disabled}
|
|
711
|
-
rows={8}
|
|
712
|
-
placeholder="Enter rich text content..."
|
|
713
|
-
/>
|
|
714
|
-
{field.admin?.description && !error && (
|
|
715
|
-
<p className="kyro-form-help">{field.admin.description}</p>
|
|
716
|
-
)}
|
|
717
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
718
|
-
</div>
|
|
719
|
-
);
|
|
720
|
-
|
|
721
|
-
default: {
|
|
722
|
-
const anyField = field as any;
|
|
723
|
-
return (
|
|
724
|
-
<div key={anyField.name} className="kyro-form-field">
|
|
725
|
-
<span className="kyro-form-unsupported">
|
|
726
|
-
Unsupported field type: {anyField.type}
|
|
727
|
-
</span>
|
|
1823
|
+
</div>
|
|
728
1824
|
</div>
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1825
|
+
</div>
|
|
1826
|
+
</header>
|
|
1827
|
+
);
|
|
732
1828
|
};
|
|
733
1829
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
</div>
|
|
738
|
-
);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function renderSubField(
|
|
742
|
-
field: Field,
|
|
743
|
-
value: any,
|
|
744
|
-
onChange: (value: any) => void,
|
|
745
|
-
disabled?: boolean,
|
|
746
|
-
) {
|
|
747
|
-
switch (field.type) {
|
|
748
|
-
case "text":
|
|
749
|
-
case "email":
|
|
750
|
-
case "password":
|
|
1830
|
+
const renderEditView = () => {
|
|
1831
|
+
// Single layout: no split grid, no sidebar column — just a clean field list
|
|
1832
|
+
if (layout === "single") {
|
|
751
1833
|
return (
|
|
752
|
-
<
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
disabled={disabled}
|
|
758
|
-
/>
|
|
759
|
-
);
|
|
760
|
-
case "number":
|
|
761
|
-
return (
|
|
762
|
-
<input
|
|
763
|
-
type="number"
|
|
764
|
-
className="kyro-form-input kyro-form-number"
|
|
765
|
-
value={value ?? ""}
|
|
766
|
-
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
|
767
|
-
disabled={disabled}
|
|
768
|
-
/>
|
|
769
|
-
);
|
|
770
|
-
case "checkbox":
|
|
771
|
-
return (
|
|
772
|
-
<input
|
|
773
|
-
type="checkbox"
|
|
774
|
-
checked={value || false}
|
|
775
|
-
onChange={(e) => onChange(e.target.checked)}
|
|
776
|
-
disabled={disabled}
|
|
777
|
-
/>
|
|
778
|
-
);
|
|
779
|
-
case "textarea":
|
|
780
|
-
return (
|
|
781
|
-
<textarea
|
|
782
|
-
className="kyro-form-input kyro-form-textarea"
|
|
783
|
-
value={value || ""}
|
|
784
|
-
onChange={(e) => onChange(e.target.value)}
|
|
785
|
-
disabled={disabled}
|
|
786
|
-
rows={3}
|
|
787
|
-
/>
|
|
788
|
-
);
|
|
789
|
-
case "select":
|
|
790
|
-
return (
|
|
791
|
-
<select
|
|
792
|
-
className="kyro-form-input kyro-form-select"
|
|
793
|
-
value={value || ""}
|
|
794
|
-
onChange={(e) => onChange(e.target.value)}
|
|
795
|
-
disabled={disabled}
|
|
796
|
-
>
|
|
797
|
-
<option value="">Select...</option>
|
|
798
|
-
{(field as any).options?.map((opt: any) => (
|
|
799
|
-
<option key={opt.value || opt} value={opt.value || opt}>
|
|
800
|
-
{opt.label || opt}
|
|
801
|
-
</option>
|
|
802
|
-
))}
|
|
803
|
-
</select>
|
|
804
|
-
);
|
|
805
|
-
default:
|
|
806
|
-
return (
|
|
807
|
-
<input
|
|
808
|
-
type="text"
|
|
809
|
-
className="kyro-form-input"
|
|
810
|
-
value={value || ""}
|
|
811
|
-
onChange={(e) => onChange(e.target.value)}
|
|
812
|
-
disabled={disabled}
|
|
813
|
-
/>
|
|
1834
|
+
<div className="w-full space-y-8">
|
|
1835
|
+
<div className="surface-tile p-8 space-y-8">
|
|
1836
|
+
{config.fields.map((f) => renderField(f))}
|
|
1837
|
+
</div>
|
|
1838
|
+
</div>
|
|
814
1839
|
);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
interface UploadFieldProps {
|
|
819
|
-
field: any;
|
|
820
|
-
value: any;
|
|
821
|
-
onChange: (value: any) => void;
|
|
822
|
-
disabled?: boolean;
|
|
823
|
-
error?: string;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
function UploadField({
|
|
827
|
-
field,
|
|
828
|
-
value,
|
|
829
|
-
onChange,
|
|
830
|
-
disabled,
|
|
831
|
-
error,
|
|
832
|
-
}: UploadFieldProps) {
|
|
833
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
834
|
-
const [preview, setPreview] = useState<string | null>(null);
|
|
835
|
-
const [isDragging, setIsDragging] = useState(false);
|
|
836
|
-
|
|
837
|
-
const isImage = (file: File) => file.type.startsWith("image/");
|
|
838
|
-
|
|
839
|
-
const handleFile = (file: File) => {
|
|
840
|
-
if (isImage(file)) {
|
|
841
|
-
const reader = new FileReader();
|
|
842
|
-
reader.onloadend = () => {
|
|
843
|
-
setPreview(reader.result as string);
|
|
844
|
-
};
|
|
845
|
-
reader.readAsDataURL(file);
|
|
846
1840
|
}
|
|
847
|
-
onChange({
|
|
848
|
-
filename: file.name,
|
|
849
|
-
size: file.size,
|
|
850
|
-
type: file.type,
|
|
851
|
-
url: URL.createObjectURL(file),
|
|
852
|
-
});
|
|
853
|
-
};
|
|
854
1841
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
};
|
|
1842
|
+
// Default split layout
|
|
1843
|
+
const showRightColumn = !sidebarCollapsed && !showPreview;
|
|
1844
|
+
const hasSidebarFields =
|
|
1845
|
+
config.fields.some((f) => f.admin?.position === "sidebar") &&
|
|
1846
|
+
!showPreview;
|
|
861
1847
|
|
|
862
|
-
|
|
863
|
-
<div className="kyro-form-field">
|
|
864
|
-
<label className="kyro-form-label">
|
|
865
|
-
{field.label || field.name}
|
|
866
|
-
{field.required && <span className="kyro-form-label-required">*</span>}
|
|
867
|
-
</label>
|
|
1848
|
+
return (
|
|
868
1849
|
<div
|
|
869
|
-
className={`
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
onClick={() => inputRef.current?.click()}
|
|
1850
|
+
className={`w-full mx-auto grid gap-8 pb-32 transition-all duration-700 ${
|
|
1851
|
+
showPreview
|
|
1852
|
+
? "grid-cols-1 lg:grid-cols-2"
|
|
1853
|
+
: sidebarCollapsed || !hasSidebarFields
|
|
1854
|
+
? "grid-cols-1"
|
|
1855
|
+
: "grid-cols-1 lg:grid-cols-[1fr_380px]"
|
|
1856
|
+
}`}
|
|
877
1857
|
>
|
|
878
|
-
<
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1858
|
+
<div className="space-y-8 animate-in fade-in slide-in-from-left-4 duration-500">
|
|
1859
|
+
{config.tabs ? (
|
|
1860
|
+
renderField({ type: "tabs", tabs: config.tabs } as any)
|
|
1861
|
+
) : (
|
|
1862
|
+
<div className="surface-tile p-8 space-y-8">
|
|
1863
|
+
{config.fields
|
|
1864
|
+
.filter(
|
|
1865
|
+
(f) => !f.admin?.position || f.admin.position === "main",
|
|
1866
|
+
)
|
|
1867
|
+
.map((f) => renderField(f))}
|
|
1868
|
+
</div>
|
|
1869
|
+
)}
|
|
1870
|
+
</div>
|
|
1871
|
+
|
|
1872
|
+
{showPreview ? (
|
|
1873
|
+
<div className="sticky top-36 h-[calc(100vh-280px)] animate-in fade-in slide-in-from-right-10 duration-700">
|
|
1874
|
+
<div className="w-full h-full rounded-3xl border border-[var(--kyro-border)] bg-[var(--kyro-bg-secondary)] shadow-2xl overflow-hidden relative group">
|
|
1875
|
+
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
|
|
1876
|
+
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
|
1877
|
+
<span className="text-[10px] font-black uppercase tracking-widest text-white/60">
|
|
1878
|
+
Live Preview Mode
|
|
1879
|
+
</span>
|
|
1880
|
+
</div>
|
|
1881
|
+
<iframe
|
|
1882
|
+
src={`/${collectionSlug}/${formData.slug || formData.id}?preview=true`}
|
|
1883
|
+
className="w-full h-full border-none"
|
|
1884
|
+
title="Live Preview"
|
|
1885
|
+
/>
|
|
1886
|
+
<div className="absolute inset-0 bg-transparent pointer-events-none border-[12px] border-[var(--kyro-surface)] rounded-3xl" />
|
|
1887
|
+
</div>
|
|
1888
|
+
</div>
|
|
1889
|
+
) : sidebarCollapsed ? null : (
|
|
1890
|
+
<div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
|
|
1891
|
+
{config.fields.some((f) => f.admin?.position === "sidebar") && (
|
|
1892
|
+
<div className="surface-tile p-6 space-y-6">
|
|
1893
|
+
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
|
1894
|
+
Settings
|
|
1895
|
+
</h3>
|
|
1896
|
+
{config.fields
|
|
1897
|
+
.filter((f) => f.admin?.position === "sidebar")
|
|
1898
|
+
.map((f) => renderField(f))}
|
|
1899
|
+
</div>
|
|
1900
|
+
)}
|
|
1901
|
+
</div>
|
|
1902
|
+
)}
|
|
1903
|
+
</div>
|
|
1904
|
+
);
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
const renderVersionView = () => (
|
|
1908
|
+
<div className="w-full animate-in fade-in slide-in-from-bottom-4 pb-12">
|
|
1909
|
+
<div className="surface-tile p-0 overflow-hidden">
|
|
1910
|
+
<div className="px-6 py-4 border-b border-[var(--kyro-border)] flex items-center justify-between">
|
|
1911
|
+
<div>
|
|
1912
|
+
<h2 className="text-lg font-bold text-[var(--kyro-text-primary)]">
|
|
1913
|
+
Version History
|
|
1914
|
+
</h2>
|
|
1915
|
+
<p className="text-[11px] text-[var(--kyro-text-muted)] mt-0.5">
|
|
1916
|
+
{compareMode
|
|
1917
|
+
? `Select 2 versions · ${compareSelected.length}/2 chosen`
|
|
1918
|
+
: `${versions.length} snapshot${versions.length !== 1 ? "s" : ""} · Auto-saved`}
|
|
1919
|
+
</p>
|
|
1920
|
+
</div>
|
|
1921
|
+
<div className="flex items-center gap-2">
|
|
1922
|
+
{compareMode && compareSelected.length === 2 && (
|
|
1923
|
+
<button
|
|
1924
|
+
type="button"
|
|
1925
|
+
onClick={handleCompareVersions}
|
|
1926
|
+
disabled={loadingDiffs}
|
|
1927
|
+
className="px-3 py-1.5 rounded-lg bg-[var(--kyro-primary)] text-white text-[11px] font-bold uppercase tracking-wider hover:opacity-90 disabled:opacity-50"
|
|
1928
|
+
>
|
|
1929
|
+
{loadingDiffs ? "Comparing..." : "Compare"}
|
|
1930
|
+
</button>
|
|
1931
|
+
)}
|
|
1932
|
+
<button
|
|
1933
|
+
type="button"
|
|
1934
|
+
onClick={() => {
|
|
1935
|
+
setCompareMode(!compareMode);
|
|
1936
|
+
setCompareSelected([]);
|
|
1937
|
+
setCompareDiffs([]);
|
|
1938
|
+
}}
|
|
1939
|
+
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all ${
|
|
1940
|
+
compareMode
|
|
1941
|
+
? "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
1942
|
+
: "border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
1943
|
+
}`}
|
|
1944
|
+
>
|
|
1945
|
+
{compareMode ? "Done" : "Compare"}
|
|
1946
|
+
</button>
|
|
1947
|
+
</div>
|
|
1948
|
+
</div>
|
|
1949
|
+
|
|
1950
|
+
{compareDiffs.length > 0 && (
|
|
1951
|
+
<div className="border-b border-[var(--kyro-border)]">
|
|
1952
|
+
<div className="px-6 py-3 flex items-center justify-between">
|
|
1953
|
+
<span className="text-[11px] font-bold text-[var(--kyro-text-primary)] uppercase tracking-wider">
|
|
1954
|
+
{compareDiffs.length} change
|
|
1955
|
+
{compareDiffs.length !== 1 ? "s" : ""}
|
|
899
1956
|
</span>
|
|
900
1957
|
<button
|
|
901
1958
|
type="button"
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
e.stopPropagation();
|
|
905
|
-
inputRef.current?.click();
|
|
906
|
-
}}
|
|
1959
|
+
onClick={() => setCompareDiffs([])}
|
|
1960
|
+
className="p-1 rounded hover:bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-muted)]"
|
|
907
1961
|
>
|
|
908
|
-
|
|
1962
|
+
<svg
|
|
1963
|
+
className="w-3.5 h-3.5"
|
|
1964
|
+
viewBox="0 0 24 24"
|
|
1965
|
+
fill="none"
|
|
1966
|
+
stroke="currentColor"
|
|
1967
|
+
strokeWidth="2.5"
|
|
1968
|
+
>
|
|
1969
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
1970
|
+
</svg>
|
|
909
1971
|
</button>
|
|
910
1972
|
</div>
|
|
1973
|
+
<div className="max-h-[400px] overflow-y-auto">
|
|
1974
|
+
{compareDiffs.map((d, i) => (
|
|
1975
|
+
<div
|
|
1976
|
+
key={i}
|
|
1977
|
+
className="grid grid-cols-4 gap-3 px-6 py-2.5 text-[11px] font-mono border-t border-[var(--kyro-border)] hover:bg-[var(--kyro-bg-secondary)]"
|
|
1978
|
+
>
|
|
1979
|
+
<div className="text-[var(--kyro-text-muted)] truncate">
|
|
1980
|
+
{d.field}
|
|
1981
|
+
</div>
|
|
1982
|
+
<div className="text-[var(--kyro-text-muted)] truncate">
|
|
1983
|
+
{typeof d.oldValue === "object"
|
|
1984
|
+
? JSON.stringify(d.oldValue)
|
|
1985
|
+
: String(d.oldValue ?? "null")}
|
|
1986
|
+
</div>
|
|
1987
|
+
<div className="col-span-2 text-[var(--kyro-text-primary)] truncate">
|
|
1988
|
+
{typeof d.newValue === "object"
|
|
1989
|
+
? JSON.stringify(d.newValue)
|
|
1990
|
+
: String(d.newValue ?? "null")}
|
|
1991
|
+
</div>
|
|
1992
|
+
</div>
|
|
1993
|
+
))}
|
|
1994
|
+
</div>
|
|
1995
|
+
</div>
|
|
1996
|
+
)}
|
|
1997
|
+
|
|
1998
|
+
{loadingVersions ? (
|
|
1999
|
+
<div className="flex justify-center py-16">
|
|
2000
|
+
<span className="animate-spin text-[var(--kyro-primary)]">⌛</span>
|
|
2001
|
+
</div>
|
|
2002
|
+
) : versions.length === 0 ? (
|
|
2003
|
+
<div className="text-center py-16 text-[var(--kyro-text-muted)] text-sm italic">
|
|
2004
|
+
No versions yet.
|
|
911
2005
|
</div>
|
|
912
2006
|
) : (
|
|
913
|
-
<div className="
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
2007
|
+
<div className="divide-y divide-[var(--kyro-border)]">
|
|
2008
|
+
{versions.map((v, i) => {
|
|
2009
|
+
const isSelected = compareSelected.includes(v.id);
|
|
2010
|
+
const isDraftVersion = v.status === "draft";
|
|
2011
|
+
const isAutoSaved = (v.changeDescription || "")
|
|
2012
|
+
.toLowerCase()
|
|
2013
|
+
.includes("auto");
|
|
2014
|
+
|
|
2015
|
+
return (
|
|
2016
|
+
<div
|
|
2017
|
+
key={v.id}
|
|
2018
|
+
onClick={
|
|
2019
|
+
compareMode ? () => toggleCompareSelection(v.id) : undefined
|
|
2020
|
+
}
|
|
2021
|
+
className={`grid grid-cols-12 gap-3 px-6 py-3 items-center transition-all ${
|
|
2022
|
+
compareMode
|
|
2023
|
+
? isSelected
|
|
2024
|
+
? "bg-[var(--kyro-primary)]/5 cursor-pointer"
|
|
2025
|
+
: "hover:bg-[var(--kyro-bg-secondary)] cursor-pointer"
|
|
2026
|
+
: "hover:bg-[var(--kyro-bg-secondary)]"
|
|
2027
|
+
} ${isDraftVersion ? "" : ""}`}
|
|
2028
|
+
>
|
|
2029
|
+
<div className="col-span-1 flex items-center gap-2">
|
|
2030
|
+
{compareMode ? (
|
|
2031
|
+
<div
|
|
2032
|
+
className={`w-4 h-4 rounded-full border ${
|
|
2033
|
+
isSelected
|
|
2034
|
+
? "border-[var(--kyro-primary)] bg-[var(--kyro-primary)]"
|
|
2035
|
+
: "border-[var(--kyro-border)]"
|
|
2036
|
+
}`}
|
|
2037
|
+
>
|
|
2038
|
+
{isSelected && (
|
|
2039
|
+
<svg
|
|
2040
|
+
className="w-full h-full text-white p-0.5"
|
|
2041
|
+
viewBox="0 0 24 24"
|
|
2042
|
+
fill="none"
|
|
2043
|
+
stroke="currentColor"
|
|
2044
|
+
strokeWidth="3"
|
|
2045
|
+
>
|
|
2046
|
+
<path d="M20 6L9 17l-5-5" />
|
|
2047
|
+
</svg>
|
|
2048
|
+
)}
|
|
2049
|
+
</div>
|
|
2050
|
+
) : (
|
|
2051
|
+
<span className="text-[10px] font-bold text-[var(--kyro-text-muted)] w-5">
|
|
2052
|
+
{versions.length - i}
|
|
2053
|
+
</span>
|
|
2054
|
+
)}
|
|
2055
|
+
</div>
|
|
2056
|
+
<div className="col-span-4 min-w-0">
|
|
2057
|
+
<div className="text-[13px] font-medium text-[var(--kyro-text-primary)] truncate flex items-center gap-2">
|
|
2058
|
+
{v.changeDescription || "Snapshot"}
|
|
2059
|
+
{isAutoSaved && (
|
|
2060
|
+
<span className="text-[9px] px-1.5 py-0.5 bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-secondary)] rounded font-bold uppercase tracking-wider">
|
|
2061
|
+
Auto
|
|
2062
|
+
</span>
|
|
2063
|
+
)}
|
|
2064
|
+
</div>
|
|
2065
|
+
<div className="text-[11px] text-[var(--kyro-text-muted)]">
|
|
2066
|
+
{new Date(v.createdAt).toLocaleString("en-US", {
|
|
2067
|
+
month: "short",
|
|
2068
|
+
day: "numeric",
|
|
2069
|
+
hour: "2-digit",
|
|
2070
|
+
minute: "2-digit",
|
|
2071
|
+
})}
|
|
2072
|
+
</div>
|
|
2073
|
+
</div>
|
|
2074
|
+
<div className="col-span-3">
|
|
2075
|
+
{v.status && (
|
|
2076
|
+
<span
|
|
2077
|
+
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold capitalize tracking-wider ${
|
|
2078
|
+
v.status === "published"
|
|
2079
|
+
? " text-[var(--kyro-success)]"
|
|
2080
|
+
: " text-[var(--kyro-warning)]"
|
|
2081
|
+
}`}
|
|
2082
|
+
>
|
|
2083
|
+
<span
|
|
2084
|
+
className={`w-1.5 h-1.5 rounded-full ${v.status === "published" ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-warning)]"}`}
|
|
2085
|
+
/>
|
|
2086
|
+
{v.status}
|
|
2087
|
+
</span>
|
|
2088
|
+
)}
|
|
2089
|
+
</div>
|
|
2090
|
+
<div className="col-span-2 text-[11px] text-[var(--kyro-text-muted)]">
|
|
2091
|
+
{v.createdBy || "system"}
|
|
2092
|
+
</div>
|
|
2093
|
+
<div className="col-span-2 flex justify-end">
|
|
2094
|
+
{!compareMode && (
|
|
2095
|
+
<button
|
|
2096
|
+
type="button"
|
|
2097
|
+
onClick={() => handleRestoreVersion(v.id)}
|
|
2098
|
+
className="px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[11px] font-bold uppercase tracking-wider text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-primary)] hover:text-white hover:border-[var(--kyro-primary)] transition-all active:scale-95"
|
|
2099
|
+
>
|
|
2100
|
+
Restore
|
|
2101
|
+
</button>
|
|
2102
|
+
)}
|
|
2103
|
+
</div>
|
|
2104
|
+
</div>
|
|
2105
|
+
);
|
|
2106
|
+
})}
|
|
930
2107
|
</div>
|
|
931
2108
|
)}
|
|
932
2109
|
</div>
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
2110
|
+
</div>
|
|
2111
|
+
);
|
|
2112
|
+
|
|
2113
|
+
const renderApiView = () => (
|
|
2114
|
+
<div className="w-full space-y-8 animate-in fade-in slide-in-from-bottom-4">
|
|
2115
|
+
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-8">
|
|
2116
|
+
<div className="surface-tile p-8 min-w-0">
|
|
2117
|
+
<h2 className="text-xl font-black mb-6">Response Payload</h2>
|
|
2118
|
+
<div className="bg-[#0f172a] p-6 rounded-2xl border border-white/5 overflow-x-auto max-h-[800px]">
|
|
2119
|
+
<pre className="text-blue-300 text-xs font-mono whitespace-pre-wrap break-all">
|
|
2120
|
+
{JSON.stringify(formData, null, 2)}
|
|
2121
|
+
</pre>
|
|
2122
|
+
</div>
|
|
2123
|
+
</div>
|
|
2124
|
+
|
|
2125
|
+
<div className="space-y-6">
|
|
2126
|
+
<div className="surface-tile p-8 space-y-6">
|
|
2127
|
+
<h2 className="text-xl font-black mb-6">API Info</h2>
|
|
2128
|
+
|
|
2129
|
+
<div className="space-y-6">
|
|
2130
|
+
<div>
|
|
2131
|
+
<label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
|
|
2132
|
+
Reference Path
|
|
2133
|
+
</label>
|
|
2134
|
+
<div className="relative group">
|
|
2135
|
+
<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">
|
|
2136
|
+
{`/api/${collectionSlug}/${formData.id || ""}`}
|
|
2137
|
+
</code>
|
|
2138
|
+
</div>
|
|
2139
|
+
</div>
|
|
2140
|
+
|
|
2141
|
+
<div>
|
|
2142
|
+
<label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-3">
|
|
2143
|
+
Methods Allowed
|
|
2144
|
+
</label>
|
|
2145
|
+
<div className="flex gap-2">
|
|
2146
|
+
<span className="px-3 py-1.5 bg-green-500/10 text-green-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
|
|
2147
|
+
GET
|
|
2148
|
+
</span>
|
|
2149
|
+
<span className="px-3 py-1.5 bg-amber-500/10 text-amber-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
|
|
2150
|
+
PATCH
|
|
2151
|
+
</span>
|
|
2152
|
+
<span className="px-3 py-1.5 bg-red-500/10 text-red-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
|
|
2153
|
+
DELETE
|
|
2154
|
+
</span>
|
|
2155
|
+
</div>
|
|
2156
|
+
</div>
|
|
2157
|
+
|
|
2158
|
+
<div className="pt-6 border-t border-[var(--kyro-border)]">
|
|
2159
|
+
<label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-4">
|
|
2160
|
+
Security Policy
|
|
2161
|
+
</label>
|
|
2162
|
+
<div className="space-y-3">
|
|
2163
|
+
{[
|
|
2164
|
+
{
|
|
2165
|
+
id: "auth-required",
|
|
2166
|
+
label: "Authorization required",
|
|
2167
|
+
checked: true,
|
|
2168
|
+
},
|
|
2169
|
+
{
|
|
2170
|
+
id: "auth-admin",
|
|
2171
|
+
label: "System administrator only",
|
|
2172
|
+
checked: false,
|
|
2173
|
+
},
|
|
2174
|
+
{
|
|
2175
|
+
id: "auth-api",
|
|
2176
|
+
label: "API Key authentication allowed",
|
|
2177
|
+
checked: true,
|
|
2178
|
+
},
|
|
2179
|
+
].map((item) => (
|
|
2180
|
+
<label
|
|
2181
|
+
key={item.id}
|
|
2182
|
+
className="flex items-center gap-3 cursor-pointer group"
|
|
2183
|
+
>
|
|
2184
|
+
<div
|
|
2185
|
+
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)]"}`}
|
|
2186
|
+
>
|
|
2187
|
+
{item.checked && (
|
|
2188
|
+
<svg
|
|
2189
|
+
width="10"
|
|
2190
|
+
height="10"
|
|
2191
|
+
viewBox="0 0 24 24"
|
|
2192
|
+
fill="none"
|
|
2193
|
+
stroke="white"
|
|
2194
|
+
strokeWidth="4"
|
|
2195
|
+
>
|
|
2196
|
+
<path d="M20 6L9 17l-5-5" />
|
|
2197
|
+
</svg>
|
|
2198
|
+
)}
|
|
2199
|
+
</div>
|
|
2200
|
+
<span className="text-xs font-medium text-[var(--kyro-text-secondary)] group-hover:text-[var(--kyro-text-primary)] transition-colors">
|
|
2201
|
+
{item.label}
|
|
2202
|
+
</span>
|
|
2203
|
+
</label>
|
|
2204
|
+
))}
|
|
2205
|
+
</div>
|
|
2206
|
+
</div>
|
|
2207
|
+
|
|
2208
|
+
<div className="pt-6 border-t border-[var(--kyro-border)]">
|
|
2209
|
+
<label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
|
|
2210
|
+
Usage Help
|
|
2211
|
+
</label>
|
|
2212
|
+
<p className="text-[11px] text-[var(--kyro-text-secondary)] leading-relaxed">
|
|
2213
|
+
Include the{" "}
|
|
2214
|
+
<code className="text-[var(--kyro-text-primary)] font-bold">
|
|
2215
|
+
Authorization: Bearer <token>
|
|
2216
|
+
</code>{" "}
|
|
2217
|
+
header to perform write operations on this document.
|
|
2218
|
+
</p>
|
|
2219
|
+
</div>
|
|
2220
|
+
</div>
|
|
2221
|
+
</div>
|
|
2222
|
+
</div>
|
|
2223
|
+
</div>
|
|
2224
|
+
</div>
|
|
2225
|
+
);
|
|
2226
|
+
|
|
2227
|
+
return (
|
|
2228
|
+
<div className="flex flex-col h-full">
|
|
2229
|
+
{layout !== "single" && renderHeader()}
|
|
2230
|
+
<main className="w-full">
|
|
2231
|
+
{view === "edit" && renderEditView()}
|
|
2232
|
+
{view === "version" && renderVersionView()}
|
|
2233
|
+
{view === "api" && renderApiView()}
|
|
2234
|
+
</main>
|
|
2235
|
+
<ConfirmModal
|
|
2236
|
+
open={confirmModal.open}
|
|
2237
|
+
onClose={() => setConfirmModal({ ...confirmModal, open: false })}
|
|
2238
|
+
onConfirm={() => {
|
|
2239
|
+
confirmModal.onConfirm();
|
|
2240
|
+
setConfirmModal({ ...confirmModal, open: false });
|
|
2241
|
+
}}
|
|
2242
|
+
title={confirmModal.title}
|
|
2243
|
+
message={confirmModal.message}
|
|
2244
|
+
variant={confirmModal.danger ? "danger" : "default"}
|
|
2245
|
+
/>
|
|
2246
|
+
<UIModal
|
|
2247
|
+
open={alertModal.open}
|
|
2248
|
+
onClose={() => setAlertModal({ ...alertModal, open: false })}
|
|
2249
|
+
title={alertModal.title}
|
|
2250
|
+
size="sm"
|
|
2251
|
+
footer={
|
|
2252
|
+
<button
|
|
2253
|
+
type="button"
|
|
2254
|
+
onClick={() => setAlertModal({ ...alertModal, open: false })}
|
|
2255
|
+
className="px-4 py-2 rounded-lg font-medium text-sm bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] hover:opacity-90 transition-colors"
|
|
2256
|
+
>
|
|
2257
|
+
OK
|
|
2258
|
+
</button>
|
|
2259
|
+
}
|
|
2260
|
+
>
|
|
2261
|
+
<p className="text-[var(--kyro-text-secondary)]">
|
|
2262
|
+
{alertModal.message}
|
|
2263
|
+
</p>
|
|
2264
|
+
</UIModal>
|
|
937
2265
|
</div>
|
|
938
2266
|
);
|
|
939
2267
|
}
|
|
@@ -963,33 +2291,33 @@ function RelationshipField({
|
|
|
963
2291
|
? field.relationTo[0]
|
|
964
2292
|
: field.relationTo;
|
|
965
2293
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
.
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
}
|
|
980
|
-
}, [isOpen, targetCollection]);
|
|
2294
|
+
const fetchOptions = () => {
|
|
2295
|
+
setLoading(true);
|
|
2296
|
+
fetch(`/api/${targetCollection}?limit=50`)
|
|
2297
|
+
.then((res) => res.json())
|
|
2298
|
+
.then((data) => {
|
|
2299
|
+
setOptions(data.docs || []);
|
|
2300
|
+
setLoading(false);
|
|
2301
|
+
})
|
|
2302
|
+
.catch((err) => {
|
|
2303
|
+
console.error("Failed to fetch relations:", err);
|
|
2304
|
+
setLoading(false);
|
|
2305
|
+
});
|
|
2306
|
+
};
|
|
981
2307
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
return searchableFields.some(
|
|
986
|
-
(key) => opt[key] && String(opt[key]).toLowerCase().includes(term),
|
|
987
|
-
);
|
|
988
|
-
});
|
|
2308
|
+
useEffect(() => {
|
|
2309
|
+
fetchOptions();
|
|
2310
|
+
}, [targetCollection]);
|
|
989
2311
|
|
|
990
2312
|
const getLabel = (opt: any) => {
|
|
991
2313
|
if (!opt) return "";
|
|
992
|
-
return
|
|
2314
|
+
return (
|
|
2315
|
+
opt.title || opt.name || opt.label || opt.filename || opt.slug || opt.id
|
|
2316
|
+
);
|
|
2317
|
+
};
|
|
2318
|
+
|
|
2319
|
+
const findOptionById = (id: string) => {
|
|
2320
|
+
return options.find((opt) => opt.id === id);
|
|
993
2321
|
};
|
|
994
2322
|
|
|
995
2323
|
const isSelected = (optId: string) => {
|
|
@@ -1018,6 +2346,33 @@ function RelationshipField({
|
|
|
1018
2346
|
}
|
|
1019
2347
|
};
|
|
1020
2348
|
|
|
2349
|
+
const renderSelectedValue = () => {
|
|
2350
|
+
if (!value) return null;
|
|
2351
|
+
if (isMultiple && Array.isArray(value)) {
|
|
2352
|
+
if (value.length === 0) return "None selected";
|
|
2353
|
+
return value
|
|
2354
|
+
.map((v) => {
|
|
2355
|
+
const id = v.id || v;
|
|
2356
|
+
const opt = findOptionById(id);
|
|
2357
|
+
return opt ? getLabel(opt) : id;
|
|
2358
|
+
})
|
|
2359
|
+
.join(", ");
|
|
2360
|
+
}
|
|
2361
|
+
const id = value.id || value;
|
|
2362
|
+
const opt = findOptionById(id);
|
|
2363
|
+
return opt ? getLabel(opt) : id;
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
const filteredOptions = search
|
|
2367
|
+
? (options || []).filter((opt) => {
|
|
2368
|
+
const term = search.toLowerCase();
|
|
2369
|
+
const searchableFields = ["title", "name", "label", "filename", "slug"];
|
|
2370
|
+
return searchableFields.some(
|
|
2371
|
+
(key) => opt[key] && String(opt[key]).toLowerCase().includes(term),
|
|
2372
|
+
);
|
|
2373
|
+
})
|
|
2374
|
+
: options || [];
|
|
2375
|
+
|
|
1021
2376
|
return (
|
|
1022
2377
|
<div className="kyro-form-field">
|
|
1023
2378
|
<label className="kyro-form-label">
|
|
@@ -1041,15 +2396,7 @@ function RelationshipField({
|
|
|
1041
2396
|
|
|
1042
2397
|
<div className="kyro-form-relationship-value">
|
|
1043
2398
|
{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
|
-
)
|
|
2399
|
+
renderSelectedValue()
|
|
1053
2400
|
) : (
|
|
1054
2401
|
<span className="kyro-form-relationship-empty">
|
|
1055
2402
|
Click to search and select...
|
|
@@ -1099,7 +2446,7 @@ function RelationshipField({
|
|
|
1099
2446
|
>
|
|
1100
2447
|
<span>{getLabel(opt)}</span>
|
|
1101
2448
|
<span className="kyro-relation-modal-item-id">
|
|
1102
|
-
({opt.id.slice(0, 8)}...)
|
|
2449
|
+
{opt.id ? `(${String(opt.id).slice(0, 8)}...)` : ""}
|
|
1103
2450
|
</span>
|
|
1104
2451
|
</button>
|
|
1105
2452
|
))
|
|
@@ -1121,3 +2468,41 @@ function RelationshipField({
|
|
|
1121
2468
|
</div>
|
|
1122
2469
|
);
|
|
1123
2470
|
}
|
|
2471
|
+
|
|
2472
|
+
// SEO Utilities
|
|
2473
|
+
function stripHtml(html: string) {
|
|
2474
|
+
if (typeof html !== "string") return "";
|
|
2475
|
+
return html.replace(/<[^>]*>?/gm, "").trim();
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
const SeoPreview = ({
|
|
2479
|
+
title,
|
|
2480
|
+
description,
|
|
2481
|
+
slug,
|
|
2482
|
+
}: {
|
|
2483
|
+
title: string;
|
|
2484
|
+
description: string;
|
|
2485
|
+
slug: string;
|
|
2486
|
+
}) => (
|
|
2487
|
+
<div className="bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg p-6 max-w-2xl shadow-sm transition-colors duration-300">
|
|
2488
|
+
<div className="flex items-center gap-2 mb-2">
|
|
2489
|
+
<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)]">
|
|
2490
|
+
K
|
|
2491
|
+
</div>
|
|
2492
|
+
<div className="flex flex-col">
|
|
2493
|
+
<span className="text-sm font-medium text-[var(--kyro-text-primary)] leading-tight">
|
|
2494
|
+
kyro-cms.com
|
|
2495
|
+
</span>
|
|
2496
|
+
<span className="text-[12px] text-[var(--kyro-text-secondary)] leading-tight opacity-80">
|
|
2497
|
+
https://kyro-cms.com › posts › {slug}
|
|
2498
|
+
</span>
|
|
2499
|
+
</div>
|
|
2500
|
+
</div>
|
|
2501
|
+
<h3 className="text-[20px] text-[#2563eb] dark:text-[#60a5fa] font-medium hover:underline cursor-pointer mb-1 leading-tight transition-colors">
|
|
2502
|
+
{title}
|
|
2503
|
+
</h3>
|
|
2504
|
+
<p className="text-[14px] text-[var(--kyro-text-secondary)] leading-relaxed line-clamp-2">
|
|
2505
|
+
{description}
|
|
2506
|
+
</p>
|
|
2507
|
+
</div>
|
|
2508
|
+
);
|