@kyro-cms/admin 0.10.6 → 0.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +38 -15122
- package/dist/index.css +1 -1698
- package/dist/index.js +38 -15036
- package/package.json +1 -1
- package/src/components/ApiKeysManager.tsx +33 -126
- package/src/components/AutoForm.tsx +130 -1192
- package/src/components/PluginsManager.tsx +13 -141
- package/src/components/SettingsPage.tsx +0 -1
- package/src/components/UserMenu.tsx +17 -1
- package/src/components/autoform/AutoFormApiView.tsx +96 -0
- package/src/components/autoform/AutoFormEditView.tsx +91 -0
- package/src/components/autoform/AutoFormHeader.tsx +488 -0
- package/src/components/autoform/AutoFormVersionView.tsx +283 -0
- package/src/hooks/useAutoFormState.ts +3 -1
- package/src/lib/autoform-store.ts +8 -0
- package/dist/index.cjs.map +0 -1
- package/dist/index.css.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChevronRight, Check, ExternalLink, X } from "./ui/icons";
|
|
1
|
+
import { ChevronRight, Check, ExternalLink, X, AlertTriangle } from "./ui/icons";
|
|
2
2
|
import { useState, useRef, useEffect } from "react";
|
|
3
3
|
import type {
|
|
4
4
|
CollectionConfig,
|
|
@@ -39,6 +39,10 @@ import type { SplitButtonStatus } from "./ui/SplitButton";
|
|
|
39
39
|
import { TabsLayout } from "./fields/TabsLayout";
|
|
40
40
|
import { GroupLayout } from "./fields/GroupLayout";
|
|
41
41
|
import { ArrayLayout } from "./fields/ArrayLayout";
|
|
42
|
+
import { AutoFormHeader } from "./autoform/AutoFormHeader";
|
|
43
|
+
import { AutoFormEditView } from "./autoform/AutoFormEditView";
|
|
44
|
+
import { AutoFormVersionView } from "./autoform/AutoFormVersionView";
|
|
45
|
+
import { AutoFormApiView } from "./autoform/AutoFormApiView";
|
|
42
46
|
|
|
43
47
|
interface AutoFormProps {
|
|
44
48
|
config: CollectionConfig | GlobalConfig;
|
|
@@ -57,10 +61,12 @@ interface AutoFormProps {
|
|
|
57
61
|
documentStatus?: string;
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
const EMPTY_OBJECT = {};
|
|
65
|
+
|
|
60
66
|
export function AutoForm({
|
|
61
67
|
config: propConfig,
|
|
62
|
-
data: initialData =
|
|
63
|
-
errors =
|
|
68
|
+
data: initialData = EMPTY_OBJECT,
|
|
69
|
+
errors = EMPTY_OBJECT as Record<string, string>,
|
|
64
70
|
onChange,
|
|
65
71
|
disabled: propDisabled,
|
|
66
72
|
collectionSlug,
|
|
@@ -72,13 +78,29 @@ export function AutoForm({
|
|
|
72
78
|
onActionError,
|
|
73
79
|
justSaved,
|
|
74
80
|
}: AutoFormProps) {
|
|
75
|
-
//
|
|
76
|
-
|
|
81
|
+
// Use the serialized config from the Astro page prop (SSR + client match).
|
|
82
|
+
// Only fall back to the live client-side config when no prop was passed.
|
|
83
|
+
const activeConfig = propConfig || (globalSlug
|
|
77
84
|
? globals[globalSlug]
|
|
78
85
|
: collectionSlug
|
|
79
86
|
? collections[collectionSlug]
|
|
80
|
-
:
|
|
81
|
-
|
|
87
|
+
: null);
|
|
88
|
+
|
|
89
|
+
const [liveConfig, setLiveConfig] = useState<any>(activeConfig);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (globalSlug === "storage-settings") {
|
|
93
|
+
apiGet("/api/kyro/schema").then((schema: any) => {
|
|
94
|
+
if (schema?.globals?.["storage-settings"]) {
|
|
95
|
+
setLiveConfig(schema.globals["storage-settings"]);
|
|
96
|
+
}
|
|
97
|
+
}).catch(err => console.error("[AutoForm] Failed to fetch dynamic schema", err));
|
|
98
|
+
} else {
|
|
99
|
+
setLiveConfig(activeConfig);
|
|
100
|
+
}
|
|
101
|
+
}, [globalSlug, activeConfig]);
|
|
102
|
+
|
|
103
|
+
const config = liveConfig || activeConfig;
|
|
82
104
|
|
|
83
105
|
|
|
84
106
|
const { confirm } = useUIStore();
|
|
@@ -135,6 +157,7 @@ export function AutoForm({
|
|
|
135
157
|
initialData,
|
|
136
158
|
collectionSlug,
|
|
137
159
|
globalSlug,
|
|
160
|
+
documentId,
|
|
138
161
|
onChange,
|
|
139
162
|
onActionSuccess,
|
|
140
163
|
onActionError,
|
|
@@ -147,33 +170,56 @@ export function AutoForm({
|
|
|
147
170
|
const [now, setNow] = useState(Date.now());
|
|
148
171
|
const disabled = propDisabled;
|
|
149
172
|
const [clientLoading, setClientLoading] = useState(false);
|
|
173
|
+
const [fetchError, setFetchError] = useState(false);
|
|
174
|
+
const [retryTick, setRetryTick] = useState(0);
|
|
175
|
+
const docCacheRef = useRef(new Map<string, { data: Record<string, unknown>; ts: number }>());
|
|
176
|
+
const CACHE_TTL = 30_000;
|
|
150
177
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// Client-side fetch when SSR didn't provide data
|
|
178
|
+
// Client-side fetch with cache, stale-while-revalidate, and abort
|
|
154
179
|
useEffect(() => {
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
if (!
|
|
158
|
-
if (fetchedRef.current) return;
|
|
180
|
+
const cacheKey = globalSlug ? `global:${globalSlug}` : `${collectionSlug}:${documentId}`;
|
|
181
|
+
const shouldFetch = globalSlug || (collectionSlug && documentId && documentId !== "new");
|
|
182
|
+
if (!shouldFetch) return;
|
|
159
183
|
if (initialData && Object.keys(initialData).length > 0) return;
|
|
160
184
|
|
|
161
|
-
|
|
162
|
-
|
|
185
|
+
const cached = docCacheRef.current.get(cacheKey);
|
|
186
|
+
const isFresh = cached && Date.now() - cached.ts < CACHE_TTL;
|
|
187
|
+
const isStale = cached && !isFresh;
|
|
188
|
+
|
|
189
|
+
// Fresh cache — skip fetch, render immediately
|
|
190
|
+
if (isFresh) {
|
|
191
|
+
useAutoFormStore.getState().loadDocument(cached.data, cached.data);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Stale cache — render cached data (no shimmer), re-fetch in background
|
|
196
|
+
if (isStale) {
|
|
197
|
+
useAutoFormStore.getState().loadDocument(cached.data, cached.data);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const abort = new AbortController();
|
|
201
|
+
setFetchError(false);
|
|
202
|
+
if (!cached) setClientLoading(true);
|
|
203
|
+
|
|
163
204
|
const url = globalSlug
|
|
164
205
|
? `/api/globals/${globalSlug}`
|
|
165
206
|
: `/api/${collectionSlug}/${documentId}`;
|
|
166
207
|
|
|
167
|
-
apiGet<{ data?: Record<string, unknown> }>(url, { autoToast: false })
|
|
208
|
+
apiGet<{ data?: Record<string, unknown> }>(url, { autoToast: false, signal: abort.signal })
|
|
168
209
|
.then((result) => {
|
|
169
210
|
const docData = result.data || {};
|
|
170
|
-
|
|
211
|
+
docCacheRef.current.set(cacheKey, { data: docData, ts: Date.now() });
|
|
212
|
+
useAutoFormStore.getState().loadDocument(docData, docData);
|
|
171
213
|
setClientLoading(false);
|
|
172
214
|
})
|
|
173
|
-
.catch(() => {
|
|
215
|
+
.catch((err) => {
|
|
216
|
+
if (err.name === "AbortError") return;
|
|
174
217
|
setClientLoading(false);
|
|
218
|
+
if (!cached) setFetchError(true);
|
|
175
219
|
});
|
|
176
|
-
|
|
220
|
+
|
|
221
|
+
return () => abort.abort();
|
|
222
|
+
}, [collectionSlug, documentId, globalSlug, initialData, retryTick]);
|
|
177
223
|
|
|
178
224
|
// Tick every 10s so the "saved X ago" label stays fresh
|
|
179
225
|
useEffect(() => {
|
|
@@ -416,7 +462,11 @@ export function AutoForm({
|
|
|
416
462
|
);
|
|
417
463
|
if (response?.ok) {
|
|
418
464
|
onActionSuccess?.("Document unpublished successfully");
|
|
419
|
-
|
|
465
|
+
const state = useAutoFormStore.getState();
|
|
466
|
+
state.loadDocument(
|
|
467
|
+
{ ...formData, status: 'draft' } as Record<string, unknown>,
|
|
468
|
+
{ ...formData, status: 'draft' } as Record<string, unknown>,
|
|
469
|
+
);
|
|
420
470
|
} else {
|
|
421
471
|
const error = await response?.json().catch(() => ({}));
|
|
422
472
|
toast.error(error?.error || "Failed to unpublish");
|
|
@@ -519,8 +569,6 @@ export function AutoForm({
|
|
|
519
569
|
if (response?.ok) {
|
|
520
570
|
setLocalSaveStatus("saved");
|
|
521
571
|
onActionSuccess?.("Published successfully");
|
|
522
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
523
|
-
location.reload();
|
|
524
572
|
} else {
|
|
525
573
|
const error = await response?.json().catch(() => ({}));
|
|
526
574
|
if (response?.status === 409) setAutoSaveStatus("conflict");
|
|
@@ -885,959 +933,6 @@ export function AutoForm({
|
|
|
885
933
|
}
|
|
886
934
|
};
|
|
887
935
|
|
|
888
|
-
const renderHeader = () => {
|
|
889
|
-
const docTitle = String(
|
|
890
|
-
(formData.mainTabs as { title?: string })?.title ||
|
|
891
|
-
(typeof formData.title === "object" ? "" : formData.title) ||
|
|
892
|
-
(typeof formData.name === "object" ? "" : formData.name) ||
|
|
893
|
-
"Untitled",
|
|
894
|
-
);
|
|
895
|
-
// Use status from the document (merged from version table on draft reads)
|
|
896
|
-
const docStatus = documentStatus ?? formData.status ?? 'draft';
|
|
897
|
-
const isNew = !formData.id;
|
|
898
|
-
const lastModified = formData.updatedAt
|
|
899
|
-
? new Date(formData.updatedAt as string).toLocaleString()
|
|
900
|
-
: "Just now";
|
|
901
|
-
const createdAt = formData.createdAt
|
|
902
|
-
? new Date(formData.createdAt as string).toLocaleString()
|
|
903
|
-
: "Just now";
|
|
904
|
-
|
|
905
|
-
const isDraftMode = !formData.id || documentStatus === 'draft';
|
|
906
|
-
|
|
907
|
-
// Status label shown in the header
|
|
908
|
-
const statusLabel = hasUnpublishedChanges
|
|
909
|
-
? 'Draft (unpublished changes)'
|
|
910
|
-
: docStatus === 'published'
|
|
911
|
-
? 'Published'
|
|
912
|
-
: 'Draft';
|
|
913
|
-
|
|
914
|
-
// Compact status label for mobile
|
|
915
|
-
const statusLabelMobile = hasUnpublishedChanges
|
|
916
|
-
? 'Unpublished'
|
|
917
|
-
: docStatus === 'published'
|
|
918
|
-
? 'Published'
|
|
919
|
-
: 'Draft';
|
|
920
|
-
|
|
921
|
-
const statusColor = docStatus === 'published' && !hasUnsavedChanges
|
|
922
|
-
? 'bg-[var(--kyro-success)]'
|
|
923
|
-
: hasUnpublishedChanges
|
|
924
|
-
? 'bg-[var(--kyro-warning)]'
|
|
925
|
-
: 'bg-[var(--kyro-text-muted)]';
|
|
926
|
-
|
|
927
|
-
const statusBadgeBg = docStatus === 'published' && !hasUnpublishedChanges
|
|
928
|
-
? 'bg-[var(--kyro-success)]/10 text-[var(--kyro-success)] border-[var(--kyro-success)]/20'
|
|
929
|
-
: hasUnpublishedChanges
|
|
930
|
-
? 'bg-[var(--kyro-warning)]/10 text-[var(--kyro-warning)] border-[var(--kyro-warning)]/20'
|
|
931
|
-
: 'bg-[var(--kyro-text-muted)]/10 text-[var(--kyro-text-muted)] border-[var(--kyro-text-muted)]/20';
|
|
932
|
-
|
|
933
|
-
/* ── Auto-save status indicator (shared between mobile and desktop) ─── */
|
|
934
|
-
const renderAutoSaveStatus = (compact = false) => (
|
|
935
|
-
<>
|
|
936
|
-
{autoSaveStatus === "saving" && (
|
|
937
|
-
<span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
|
|
938
|
-
<svg
|
|
939
|
-
className="animate-spin h-3 w-3 shrink-0"
|
|
940
|
-
viewBox="0 0 24 24"
|
|
941
|
-
fill="none"
|
|
942
|
-
>
|
|
943
|
-
<circle
|
|
944
|
-
className="opacity-25"
|
|
945
|
-
cx="12"
|
|
946
|
-
cy="12"
|
|
947
|
-
r="10"
|
|
948
|
-
stroke="currentColor"
|
|
949
|
-
strokeWidth="4"
|
|
950
|
-
/>
|
|
951
|
-
<path
|
|
952
|
-
className="opacity-75"
|
|
953
|
-
fill="currentColor"
|
|
954
|
-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
955
|
-
/>
|
|
956
|
-
</svg>
|
|
957
|
-
{compact ? "Saving…" : "Saving draft..."}
|
|
958
|
-
</span>
|
|
959
|
-
)}
|
|
960
|
-
{autoSaveStatus === "success" && (
|
|
961
|
-
<span className="text-[var(--kyro-success)] flex items-center gap-1">
|
|
962
|
-
<Check className="w-3.5 h-3.5 shrink-0" />
|
|
963
|
-
{compact
|
|
964
|
-
? "Saved"
|
|
965
|
-
: lastSavedAt ? `Saved ${Math.floor((Date.now() - lastSavedAt) / 60000)}m ago` : "Draft saved"}
|
|
966
|
-
</span>
|
|
967
|
-
)}
|
|
968
|
-
{autoSaveStatus === "retrying" && (
|
|
969
|
-
<span className="text-[var(--kyro-warning)] flex items-center gap-1.5">
|
|
970
|
-
<svg className="animate-spin h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="none">
|
|
971
|
-
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
972
|
-
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
973
|
-
</svg>
|
|
974
|
-
{compact ? `Retry ${retryCount}/5` : `Retrying save (${retryCount}/5)`}
|
|
975
|
-
</span>
|
|
976
|
-
)}
|
|
977
|
-
{autoSaveStatus === "offline" && (
|
|
978
|
-
<span className="text-[var(--kyro-text-muted)] flex items-center gap-1.5">
|
|
979
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
|
980
|
-
<path d="M10.61 10.61a3 3 0 0 0 4.24 4.24" />
|
|
981
|
-
<path d="M13.36 13.36a3 3 0 0 0-4.24-4.24" />
|
|
982
|
-
<path d="m2 2 20 20" />
|
|
983
|
-
<path d="M18.36 5.64a9 9 0 0 0-12.72 0" />
|
|
984
|
-
<path d="M22.61 1.39a15 15 0 0 0-21.22 0" />
|
|
985
|
-
</svg>
|
|
986
|
-
{compact ? "Offline" : "Offline — cached locally"}
|
|
987
|
-
</span>
|
|
988
|
-
)}
|
|
989
|
-
{autoSaveStatus === "error" && (
|
|
990
|
-
<span className="text-[var(--kyro-danger)]">{compact ? "Failed" : "Draft save failed"}</span>
|
|
991
|
-
)}
|
|
992
|
-
{autoSaveStatus === "conflict" && (
|
|
993
|
-
compact ? (
|
|
994
|
-
<span className="text-[var(--kyro-danger)] font-semibold">Conflict</span>
|
|
995
|
-
) : (
|
|
996
|
-
<div className="flex items-center gap-3">
|
|
997
|
-
<span className="text-[var(--kyro-danger)] font-semibold">Conflict detected</span>
|
|
998
|
-
<span className="opacity-30">—</span>
|
|
999
|
-
<button
|
|
1000
|
-
type="button"
|
|
1001
|
-
onClick={async () => {
|
|
1002
|
-
await saveDocument(formData);
|
|
1003
|
-
setAutoSaveStatus("success");
|
|
1004
|
-
}}
|
|
1005
|
-
className="text-[var(--kyro-primary)] hover:underline"
|
|
1006
|
-
>
|
|
1007
|
-
Keep my changes
|
|
1008
|
-
</button>
|
|
1009
|
-
<span className="opacity-30">|</span>
|
|
1010
|
-
<button
|
|
1011
|
-
type="button"
|
|
1012
|
-
onClick={() => {
|
|
1013
|
-
window.location.reload();
|
|
1014
|
-
}}
|
|
1015
|
-
className="text-[var(--kyro-danger)] hover:underline"
|
|
1016
|
-
>
|
|
1017
|
-
Reload server version
|
|
1018
|
-
</button>
|
|
1019
|
-
</div>
|
|
1020
|
-
)
|
|
1021
|
-
)}
|
|
1022
|
-
</>
|
|
1023
|
-
);
|
|
1024
|
-
|
|
1025
|
-
/* ── Kebab dropdown (shared between mobile and desktop) ──────────────── */
|
|
1026
|
-
const renderKebabMenu = () => !isNew && (
|
|
1027
|
-
<Dropdown
|
|
1028
|
-
trigger={
|
|
1029
|
-
<button
|
|
1030
|
-
type="button"
|
|
1031
|
-
className="kyro-btn p-2 md:p-2.5 rounded-xl border border-[var(--kyro-border)] hover:bg-[var(--kyro-bg-secondary)] transition-all"
|
|
1032
|
-
title="More actions"
|
|
1033
|
-
>
|
|
1034
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
1035
|
-
<circle cx="12" cy="5" r="1.5" />
|
|
1036
|
-
<circle cx="12" cy="12" r="1.5" />
|
|
1037
|
-
<circle cx="12" cy="19" r="1.5" />
|
|
1038
|
-
</svg>
|
|
1039
|
-
</button>
|
|
1040
|
-
}
|
|
1041
|
-
direction="down"
|
|
1042
|
-
>
|
|
1043
|
-
{!globalSlug && (
|
|
1044
|
-
<DropdownItem
|
|
1045
|
-
onClick={handleCreateNew}
|
|
1046
|
-
icon={
|
|
1047
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1048
|
-
<line x1="12" y1="5" x2="12" y2="19" />
|
|
1049
|
-
<line x1="5" y1="12" x2="19" y2="12" />
|
|
1050
|
-
</svg>
|
|
1051
|
-
}
|
|
1052
|
-
>
|
|
1053
|
-
Create New
|
|
1054
|
-
</DropdownItem>
|
|
1055
|
-
)}
|
|
1056
|
-
{!globalSlug && (
|
|
1057
|
-
<DropdownItem
|
|
1058
|
-
onClick={handleDuplicate}
|
|
1059
|
-
icon={
|
|
1060
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1061
|
-
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
1062
|
-
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
1063
|
-
</svg>
|
|
1064
|
-
}
|
|
1065
|
-
>
|
|
1066
|
-
Duplicate
|
|
1067
|
-
</DropdownItem>
|
|
1068
|
-
)}
|
|
1069
|
-
<DropdownItem
|
|
1070
|
-
onClick={() => setShowSchedulePicker(true)}
|
|
1071
|
-
icon={
|
|
1072
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1073
|
-
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
1074
|
-
<line x1="16" y1="2" x2="16" y2="6" />
|
|
1075
|
-
<line x1="8" y1="2" x2="8" y2="6" />
|
|
1076
|
-
<line x1="3" y1="10" x2="21" y2="10" />
|
|
1077
|
-
</svg>
|
|
1078
|
-
}
|
|
1079
|
-
>
|
|
1080
|
-
Schedule Publish
|
|
1081
|
-
</DropdownItem>
|
|
1082
|
-
{documentStatus === "published" && (
|
|
1083
|
-
<DropdownItem
|
|
1084
|
-
onClick={handleUnpublish}
|
|
1085
|
-
icon={
|
|
1086
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1087
|
-
<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" />
|
|
1088
|
-
<line x1="1" y1="1" x2="23" y2="23" />
|
|
1089
|
-
</svg>
|
|
1090
|
-
}
|
|
1091
|
-
>
|
|
1092
|
-
Unpublish
|
|
1093
|
-
</DropdownItem>
|
|
1094
|
-
)}
|
|
1095
|
-
{!globalSlug && (
|
|
1096
|
-
<>
|
|
1097
|
-
<DropdownSeparator />
|
|
1098
|
-
<DropdownItem
|
|
1099
|
-
onClick={handleDelete}
|
|
1100
|
-
danger
|
|
1101
|
-
icon={
|
|
1102
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1103
|
-
<polyline points="3 6 5 6 21 6" />
|
|
1104
|
-
<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" />
|
|
1105
|
-
</svg>
|
|
1106
|
-
}
|
|
1107
|
-
>
|
|
1108
|
-
Delete
|
|
1109
|
-
</DropdownItem>
|
|
1110
|
-
</>
|
|
1111
|
-
)}
|
|
1112
|
-
</Dropdown>
|
|
1113
|
-
);
|
|
1114
|
-
|
|
1115
|
-
/* ── Schedule picker popover (shared) ──────────────────────────────── */
|
|
1116
|
-
const renderSchedulePicker = () => showSchedulePicker && (
|
|
1117
|
-
<div ref={scheduleRef} className="relative">
|
|
1118
|
-
<div className="absolute right-0 top-2 p-4 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50 min-w-[260px]">
|
|
1119
|
-
<p className="text-xs font-medium mb-2">Schedule Publish</p>
|
|
1120
|
-
<input
|
|
1121
|
-
type="datetime-local"
|
|
1122
|
-
id="schedule-datetime"
|
|
1123
|
-
className="kyro-form-input text-xs mb-3 w-full"
|
|
1124
|
-
min={new Date().toISOString().slice(0, 16)}
|
|
1125
|
-
/>
|
|
1126
|
-
<div className="flex items-center gap-2 justify-end">
|
|
1127
|
-
<button
|
|
1128
|
-
type="button"
|
|
1129
|
-
onClick={() => setShowSchedulePicker(false)}
|
|
1130
|
-
className="px-3 py-1.5 text-xs kyro-btn rounded-lg"
|
|
1131
|
-
>
|
|
1132
|
-
Cancel
|
|
1133
|
-
</button>
|
|
1134
|
-
<button
|
|
1135
|
-
type="button"
|
|
1136
|
-
onClick={() => {
|
|
1137
|
-
const val = (document.getElementById("schedule-datetime") as HTMLInputElement)?.value;
|
|
1138
|
-
if (val) handleSchedulePublish(val);
|
|
1139
|
-
}}
|
|
1140
|
-
className="px-3 py-1.5 text-xs kyro-btn-success rounded-lg"
|
|
1141
|
-
>
|
|
1142
|
-
Schedule
|
|
1143
|
-
</button>
|
|
1144
|
-
</div>
|
|
1145
|
-
</div>
|
|
1146
|
-
</div>
|
|
1147
|
-
);
|
|
1148
|
-
|
|
1149
|
-
return (
|
|
1150
|
-
<>
|
|
1151
|
-
{/* ═══════════════════════════════════════════════════════════════════
|
|
1152
|
-
MOBILE HEADER (< md)
|
|
1153
|
-
Two rows: top = nav + title + actions, bottom = toolbar
|
|
1154
|
-
════════════════════════════════════════════════════════════════════ */}
|
|
1155
|
-
<header className="md:hidden border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)] backdrop-blur-md rounded-lg">
|
|
1156
|
-
{/* ── Row 1: Back, Title, Status, Primary actions ─────────────── */}
|
|
1157
|
-
<div className="flex items-center gap-2 px-3 py-2.5">
|
|
1158
|
-
{/* Back button */}
|
|
1159
|
-
<a
|
|
1160
|
-
href={`/${collectionSlug}`}
|
|
1161
|
-
className="p-1.5 rounded-lg hover:bg-[var(--kyro-bg-secondary)] transition-colors shrink-0"
|
|
1162
|
-
aria-label="Back to list"
|
|
1163
|
-
>
|
|
1164
|
-
<ChevronRight className="w-4 h-4" />
|
|
1165
|
-
</a>
|
|
1166
|
-
|
|
1167
|
-
{/* Title + status badge */}
|
|
1168
|
-
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
1169
|
-
<h1 className="text-base font-bold tracking-tight truncate min-w-0">{docTitle}</h1>
|
|
1170
|
-
<span className={`shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-medium border ${statusBadgeBg}`}>
|
|
1171
|
-
<span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
|
|
1172
|
-
{statusLabelMobile}
|
|
1173
|
-
</span>
|
|
1174
|
-
</div>
|
|
1175
|
-
|
|
1176
|
-
{/* Primary actions: Publish + Kebab */}
|
|
1177
|
-
<div className="flex items-center gap-1.5 shrink-0">
|
|
1178
|
-
<SplitButton
|
|
1179
|
-
status={documentStatus as SplitButtonStatus}
|
|
1180
|
-
saveStatus={localSaveStatus}
|
|
1181
|
-
hasChanges={hasUnsavedChanges}
|
|
1182
|
-
onPublish={handlePublish}
|
|
1183
|
-
disabled={localSaveStatus === "saving"}
|
|
1184
|
-
/>
|
|
1185
|
-
{renderKebabMenu()}
|
|
1186
|
-
{renderSchedulePicker()}
|
|
1187
|
-
</div>
|
|
1188
|
-
</div>
|
|
1189
|
-
|
|
1190
|
-
{/* ── Row 2: Compact toolbar ─────────────────────────────────── */}
|
|
1191
|
-
<div className="flex items-center justify-between px-3 py-1.5 border-t border-[var(--kyro-border)]/50 bg-[var(--kyro-bg-secondary)]/30">
|
|
1192
|
-
{/* View tabs (compact pill style) */}
|
|
1193
|
-
<div className="flex items-center gap-0.5 bg-[var(--kyro-bg-secondary)] p-0.5 rounded-lg border border-[var(--kyro-border)]/50">
|
|
1194
|
-
{(["edit", "version", "api"] as const).map((v) => (
|
|
1195
|
-
<button
|
|
1196
|
-
key={v}
|
|
1197
|
-
type="button"
|
|
1198
|
-
onClick={() => setView(v as View)}
|
|
1199
|
-
className={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${view === v
|
|
1200
|
-
? "bg-[var(--kyro-surface)] shadow-sm border border-[var(--kyro-border)] text-[var(--kyro-text-primary)]"
|
|
1201
|
-
: "text-[var(--kyro-text-secondary)] opacity-50 active:opacity-100"
|
|
1202
|
-
}`}
|
|
1203
|
-
>
|
|
1204
|
-
{v === "edit" ? "Edit" : v === "version" ? "History" : "API"}
|
|
1205
|
-
</button>
|
|
1206
|
-
))}
|
|
1207
|
-
</div>
|
|
1208
|
-
|
|
1209
|
-
{/* Auto-save status + utility buttons */}
|
|
1210
|
-
<div className="flex items-center gap-2 text-[10px] font-medium">
|
|
1211
|
-
{renderAutoSaveStatus(true)}
|
|
1212
|
-
|
|
1213
|
-
{hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
|
|
1214
|
-
<button
|
|
1215
|
-
type="button"
|
|
1216
|
-
onClick={async () => {
|
|
1217
|
-
setFormData(lastSavedData);
|
|
1218
|
-
markSaved();
|
|
1219
|
-
}}
|
|
1220
|
-
className="text-[var(--kyro-primary)] text-[10px] font-medium hover:underline"
|
|
1221
|
-
>
|
|
1222
|
-
Revert
|
|
1223
|
-
</button>
|
|
1224
|
-
)}
|
|
1225
|
-
|
|
1226
|
-
<div className="h-4 w-px bg-[var(--kyro-border)] mx-0.5" />
|
|
1227
|
-
|
|
1228
|
-
{/* Preview toggle */}
|
|
1229
|
-
<button
|
|
1230
|
-
type="button"
|
|
1231
|
-
onClick={() => setShowPreview(!showPreview)}
|
|
1232
|
-
className={`p-1.5 rounded-lg transition-all ${showPreview
|
|
1233
|
-
? "bg-[var(--kyro-primary)]/10 text-[var(--kyro-primary)]"
|
|
1234
|
-
: "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"
|
|
1235
|
-
}`}
|
|
1236
|
-
title="Live Preview"
|
|
1237
|
-
>
|
|
1238
|
-
<ExternalLink className="w-3.5 h-3.5" />
|
|
1239
|
-
</button>
|
|
1240
|
-
</div>
|
|
1241
|
-
</div>
|
|
1242
|
-
|
|
1243
|
-
{/* ── Mobile conflict resolution bar (shown when conflict detected) */}
|
|
1244
|
-
{autoSaveStatus === "conflict" && (
|
|
1245
|
-
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-[var(--kyro-danger)]/30 bg-[var(--kyro-danger)]/5">
|
|
1246
|
-
<span className="text-[11px] font-semibold text-[var(--kyro-danger)]">Conflict detected</span>
|
|
1247
|
-
<div className="flex items-center gap-2">
|
|
1248
|
-
<button
|
|
1249
|
-
type="button"
|
|
1250
|
-
onClick={async () => {
|
|
1251
|
-
await saveDocument(formData);
|
|
1252
|
-
setAutoSaveStatus("success");
|
|
1253
|
-
}}
|
|
1254
|
-
className="text-[11px] font-medium text-[var(--kyro-primary)] hover:underline"
|
|
1255
|
-
>
|
|
1256
|
-
Keep mine
|
|
1257
|
-
</button>
|
|
1258
|
-
<span className="text-[var(--kyro-text-muted)] opacity-30">|</span>
|
|
1259
|
-
<button
|
|
1260
|
-
type="button"
|
|
1261
|
-
onClick={() => window.location.reload()}
|
|
1262
|
-
className="text-[11px] font-medium text-[var(--kyro-danger)] hover:underline"
|
|
1263
|
-
>
|
|
1264
|
-
Reload
|
|
1265
|
-
</button>
|
|
1266
|
-
</div>
|
|
1267
|
-
</div>
|
|
1268
|
-
)}
|
|
1269
|
-
</header>
|
|
1270
|
-
|
|
1271
|
-
{/* ═══════════════════════════════════════════════════════════════════
|
|
1272
|
-
DESKTOP HEADER (≥ md)
|
|
1273
|
-
Original single-row layout preserved
|
|
1274
|
-
════════════════════════════════════════════════════════════════════ */}
|
|
1275
|
-
<header className="hidden md:flex surface-tile px-8 py-6 items-center justify-between sticky top-0 border-b border-[var(--kyro-border)] mb-8 bg-[var(--kyro-surface)] backdrop-blur-md">
|
|
1276
|
-
<div className="flex flex-col gap-2 min-w-0">
|
|
1277
|
-
<div className="flex items-center gap-3 flex-wrap min-w-0">
|
|
1278
|
-
<a
|
|
1279
|
-
href={`/${collectionSlug}`}
|
|
1280
|
-
className="p-2 border border-[var(--kyro-border)] rounded-xl hover:bg-[var(--kyro-bg-secondary)] transition-colors shrink-0"
|
|
1281
|
-
>
|
|
1282
|
-
<ChevronRight className="w-4 h-4" />
|
|
1283
|
-
</a>
|
|
1284
|
-
<h1 className="text-xl font-bold tracking-tighter truncate min-w-0">{docTitle}</h1>
|
|
1285
|
-
<span className={`shrink-0 inline-flex items-center gap-1.5 px-2 rounded-full text-[10px] font-regular border ${statusBadgeBg}`}>
|
|
1286
|
-
<span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
|
|
1287
|
-
{statusLabel}
|
|
1288
|
-
</span>
|
|
1289
|
-
</div>
|
|
1290
|
-
<div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
|
|
1291
|
-
{renderAutoSaveStatus(false)}
|
|
1292
|
-
{hasUnsavedChanges && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "conflict" && (
|
|
1293
|
-
<>
|
|
1294
|
-
<span className="opacity-30">—</span>
|
|
1295
|
-
<button
|
|
1296
|
-
type="button"
|
|
1297
|
-
onClick={async () => {
|
|
1298
|
-
setFormData(lastSavedData);
|
|
1299
|
-
markSaved();
|
|
1300
|
-
}}
|
|
1301
|
-
className="text-[var(--kyro-primary)] hover:underline"
|
|
1302
|
-
>
|
|
1303
|
-
Revert changes
|
|
1304
|
-
</button>
|
|
1305
|
-
</>
|
|
1306
|
-
)}
|
|
1307
|
-
{/* Live auto-save timestamp */}
|
|
1308
|
-
{lastSavedAt && autoSaveStatus !== "saving" && autoSaveStatus !== "retrying" && autoSaveStatus !== "success" && (
|
|
1309
|
-
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
1310
|
-
Draft saved {(() => {
|
|
1311
|
-
const diffMs = now - lastSavedAt;
|
|
1312
|
-
const diffMin = Math.floor(diffMs / 60_000);
|
|
1313
|
-
const diffSec = Math.floor(diffMs / 1_000);
|
|
1314
|
-
if (diffMin >= 1) return `${diffMin}m ago`;
|
|
1315
|
-
if (diffSec >= 5) return `${diffSec}s ago`;
|
|
1316
|
-
return "just now";
|
|
1317
|
-
})()}
|
|
1318
|
-
</span>
|
|
1319
|
-
)}
|
|
1320
|
-
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
1321
|
-
Modified {lastModified}
|
|
1322
|
-
</span>
|
|
1323
|
-
<span className="border-l border-[var(--kyro-border)] pl-4">
|
|
1324
|
-
Created {createdAt}
|
|
1325
|
-
</span>
|
|
1326
|
-
</div>
|
|
1327
|
-
</div>
|
|
1328
|
-
|
|
1329
|
-
<div className="flex items-center gap-6">
|
|
1330
|
-
<div className="flex items-center gap-1 bg-[var(--kyro-bg-secondary)] p-1 rounded-xl border border-[var(--kyro-border)]">
|
|
1331
|
-
{["edit", "version", "api"].map((v) => (
|
|
1332
|
-
<button
|
|
1333
|
-
key={v}
|
|
1334
|
-
type="button"
|
|
1335
|
-
onClick={() => setView(v as View)}
|
|
1336
|
-
className={`px-5 py-2 text-xs font-bold 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"}`}
|
|
1337
|
-
>
|
|
1338
|
-
{v.toUpperCase()}
|
|
1339
|
-
</button>
|
|
1340
|
-
))}
|
|
1341
|
-
</div>
|
|
1342
|
-
|
|
1343
|
-
<div className="h-8 w-px bg-[var(--kyro-border)] mx-2" />
|
|
1344
|
-
|
|
1345
|
-
<div className="flex items-center gap-3">
|
|
1346
|
-
<button
|
|
1347
|
-
type="button"
|
|
1348
|
-
onClick={() => setShowPreview(!showPreview)}
|
|
1349
|
-
className={`kyro-btn p-2.5 rounded-xl transition-all flex items-center gap-2 ${showPreview ? "shadow-lg" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
|
|
1350
|
-
title="Live Preview"
|
|
1351
|
-
>
|
|
1352
|
-
<ExternalLink className="w-4 h-4" />
|
|
1353
|
-
{showPreview && (
|
|
1354
|
-
<span className="text-[10px] font-bold tracking-widest pr-1">
|
|
1355
|
-
Active
|
|
1356
|
-
</span>
|
|
1357
|
-
)}
|
|
1358
|
-
</button>
|
|
1359
|
-
<button
|
|
1360
|
-
type="button"
|
|
1361
|
-
onClick={() => {
|
|
1362
|
-
window.dispatchEvent(new CustomEvent("toggle-sidebar"));
|
|
1363
|
-
}}
|
|
1364
|
-
className={`kyro-btn p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
|
|
1365
|
-
title="Toggle Sidebar"
|
|
1366
|
-
>
|
|
1367
|
-
<svg
|
|
1368
|
-
width="20"
|
|
1369
|
-
height="20"
|
|
1370
|
-
viewBox="0 0 24 24"
|
|
1371
|
-
fill="none"
|
|
1372
|
-
stroke="currentColor"
|
|
1373
|
-
strokeWidth="2"
|
|
1374
|
-
>
|
|
1375
|
-
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
1376
|
-
<line x1="9" y1="3" x2="9" y2="21" />
|
|
1377
|
-
</svg>
|
|
1378
|
-
</button>
|
|
1379
|
-
|
|
1380
|
-
{/* ── Publish button (no dropdown) ──────────────────────────────── */}
|
|
1381
|
-
<SplitButton
|
|
1382
|
-
status={documentStatus as SplitButtonStatus}
|
|
1383
|
-
saveStatus={localSaveStatus}
|
|
1384
|
-
hasChanges={hasUnsavedChanges}
|
|
1385
|
-
onPublish={handlePublish}
|
|
1386
|
-
disabled={localSaveStatus === "saving"}
|
|
1387
|
-
/>
|
|
1388
|
-
|
|
1389
|
-
{/* ── Kebab: document management actions ───────────────────────── */}
|
|
1390
|
-
{renderKebabMenu()}
|
|
1391
|
-
|
|
1392
|
-
{renderSchedulePicker()}
|
|
1393
|
-
</div>
|
|
1394
|
-
</div>
|
|
1395
|
-
</header>
|
|
1396
|
-
</>
|
|
1397
|
-
);
|
|
1398
|
-
};
|
|
1399
|
-
|
|
1400
|
-
const renderEditView = () => {
|
|
1401
|
-
// Single layout: no split grid, no sidebar column — just a clean field list
|
|
1402
|
-
if (layout === "single") {
|
|
1403
|
-
return (
|
|
1404
|
-
<div className="w-full space-y-6 md:space-y-8">
|
|
1405
|
-
<div className="surface-tile p-4 md:p-8 space-y-6 md:space-y-8">
|
|
1406
|
-
{config.fields.map((f: Field) => renderField(f))}
|
|
1407
|
-
</div>
|
|
1408
|
-
</div>
|
|
1409
|
-
);
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
// Default split layout
|
|
1413
|
-
const showRightColumn = !sidebarCollapsed && !showPreview;
|
|
1414
|
-
const hasSidebarFields =
|
|
1415
|
-
config.fields.some((f: Field) => f.admin?.position === "sidebar") &&
|
|
1416
|
-
!showPreview;
|
|
1417
|
-
|
|
1418
|
-
return (
|
|
1419
|
-
<div
|
|
1420
|
-
className={`w-full mx-auto grid gap-4 md:gap-8 pb-32 transition-all duration-700 ${showPreview
|
|
1421
|
-
? "grid-cols-1 lg:grid-cols-2"
|
|
1422
|
-
: sidebarCollapsed || !hasSidebarFields
|
|
1423
|
-
? "grid-cols-1"
|
|
1424
|
-
: "grid-cols-1 lg:grid-cols-[1fr_380px]"
|
|
1425
|
-
}`}
|
|
1426
|
-
>
|
|
1427
|
-
<div className="space-y-6 md:space-y-8 animate-in fade-in slide-in-from-left-4 duration-500">
|
|
1428
|
-
{config.tabs ? (
|
|
1429
|
-
renderField({ type: "tabs", tabs: config.tabs } as Field)
|
|
1430
|
-
) : (
|
|
1431
|
-
<div className="surface-tile p-4 md:p-8 space-y-6 md:space-y-8">
|
|
1432
|
-
{config.fields
|
|
1433
|
-
.filter(
|
|
1434
|
-
(f: Field) => !f.admin?.position || f.admin.position === "main",
|
|
1435
|
-
)
|
|
1436
|
-
.map((f: Field) => renderField(f))}
|
|
1437
|
-
</div>
|
|
1438
|
-
)}
|
|
1439
|
-
</div>
|
|
1440
|
-
|
|
1441
|
-
{showPreview ? (
|
|
1442
|
-
<div className="sticky top-36 h-[calc(100vh-280px)] animate-in fade-in slide-in-from-right-10 duration-700">
|
|
1443
|
-
<div className="w-full h-full rounded-3xl border border-[var(--kyro-border)] bg-[var(--kyro-bg-secondary)] shadow-2xl overflow-hidden relative group">
|
|
1444
|
-
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
|
|
1445
|
-
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
|
1446
|
-
<span className="text-[10px] font-bold tracking-widest text-white/60">
|
|
1447
|
-
Live Preview Mode
|
|
1448
|
-
</span>
|
|
1449
|
-
</div>
|
|
1450
|
-
<iframe
|
|
1451
|
-
src={`/${collectionSlug}/${formData.slug || formData.id}?preview=true`}
|
|
1452
|
-
className="w-full h-full border-none"
|
|
1453
|
-
title="Live Preview"
|
|
1454
|
-
/>
|
|
1455
|
-
<div className="absolute inset-0 bg-transparent pointer-events-none border-[12px] border-[var(--kyro-surface)] rounded-3xl" />
|
|
1456
|
-
</div>
|
|
1457
|
-
</div>
|
|
1458
|
-
) : sidebarCollapsed ? null : (
|
|
1459
|
-
<div className="space-y-4 md:space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
|
|
1460
|
-
{config.fields.some((f: Field) => f.admin?.position === "sidebar") && (
|
|
1461
|
-
<div className="surface-tile p-4 md:p-6 space-y-4 md:space-y-6">
|
|
1462
|
-
<h3 className="text-[10px] font-bold tracking-[0.2em] opacity-40">
|
|
1463
|
-
Settings
|
|
1464
|
-
</h3>
|
|
1465
|
-
{config.fields
|
|
1466
|
-
.filter((f: Field) => f.admin?.position === "sidebar")
|
|
1467
|
-
.map((f: Field) => renderField(f))}
|
|
1468
|
-
</div>
|
|
1469
|
-
)}
|
|
1470
|
-
</div>
|
|
1471
|
-
)}
|
|
1472
|
-
</div>
|
|
1473
|
-
);
|
|
1474
|
-
};
|
|
1475
|
-
|
|
1476
|
-
const renderVersionView = () => (
|
|
1477
|
-
<div className="w-full animate-in fade-in slide-in-from-bottom-4 pb-12">
|
|
1478
|
-
<div className="surface-tile p-0 overflow-hidden">
|
|
1479
|
-
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-[var(--kyro-border)] flex flex-col md:flex-row md:items-center justify-between gap-2">
|
|
1480
|
-
<div>
|
|
1481
|
-
<h2 className="text-base md:text-lg font-bold text-[var(--kyro-text-primary)]">
|
|
1482
|
-
Version History
|
|
1483
|
-
</h2>
|
|
1484
|
-
<p className="text-[11px] text-[var(--kyro-text-muted)] mt-0.5">
|
|
1485
|
-
{compareMode
|
|
1486
|
-
? `Select 2 versions · ${compareSelected.length}/2 chosen`
|
|
1487
|
-
: `${versions.length} snapshot${versions.length !== 1 ? "s" : ""} · Auto-saved`}
|
|
1488
|
-
</p>
|
|
1489
|
-
</div>
|
|
1490
|
-
<div className="flex items-center gap-2">
|
|
1491
|
-
{compareMode && compareSelected.length === 2 && (
|
|
1492
|
-
<button
|
|
1493
|
-
type="button"
|
|
1494
|
-
onClick={handleCompareVersions}
|
|
1495
|
-
disabled={loadingDiffs}
|
|
1496
|
-
className="kyro-btn kyro-btn-primary px-3 py-1.5 rounded-lg text-[11px] font-bold tracking-wider hover:opacity-90 disabled:opacity-50"
|
|
1497
|
-
>
|
|
1498
|
-
{loadingDiffs ? "Comparing..." : "Compare"}
|
|
1499
|
-
</button>
|
|
1500
|
-
)}
|
|
1501
|
-
<button
|
|
1502
|
-
type="button"
|
|
1503
|
-
onClick={() => {
|
|
1504
|
-
setCompareMode(!compareMode);
|
|
1505
|
-
setCompareSelected([]);
|
|
1506
|
-
setCompareDiffs([]);
|
|
1507
|
-
}}
|
|
1508
|
-
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold tracking-wider transition-all ${compareMode
|
|
1509
|
-
? "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
1510
|
-
: "border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
1511
|
-
}`}
|
|
1512
|
-
>
|
|
1513
|
-
{compareMode ? "Done" : "Compare"}
|
|
1514
|
-
</button>
|
|
1515
|
-
</div>
|
|
1516
|
-
</div>
|
|
1517
|
-
|
|
1518
|
-
{compareDiffs.length > 0 && (
|
|
1519
|
-
<div className="border-b border-[var(--kyro-border)]">
|
|
1520
|
-
<div className="px-6 py-3 flex items-center justify-between">
|
|
1521
|
-
<span className="text-[11px] font-bold text-[var(--kyro-text-primary)] tracking-wider">
|
|
1522
|
-
{compareDiffs.length} change
|
|
1523
|
-
{compareDiffs.length !== 1 ? "s" : ""}
|
|
1524
|
-
</span>
|
|
1525
|
-
<button
|
|
1526
|
-
type="button"
|
|
1527
|
-
onClick={() => setCompareDiffs([])}
|
|
1528
|
-
className="p-1 rounded hover:bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-muted)]"
|
|
1529
|
-
>
|
|
1530
|
-
<X className="w-4 h-4" />
|
|
1531
|
-
</button>
|
|
1532
|
-
</div>
|
|
1533
|
-
<div className="max-h-[400px] overflow-y-auto">
|
|
1534
|
-
{compareDiffs.map((d, i) => (
|
|
1535
|
-
<div
|
|
1536
|
-
key={i}
|
|
1537
|
-
className="flex flex-col md:grid md:grid-cols-4 gap-1 md:gap-3 px-4 md:px-6 py-2.5 text-[11px] font-mono border-t border-[var(--kyro-border)] hover:bg-[var(--kyro-bg-secondary)]"
|
|
1538
|
-
>
|
|
1539
|
-
<div className="text-[var(--kyro-text-muted)] truncate font-semibold md:font-normal">
|
|
1540
|
-
{d.field}
|
|
1541
|
-
</div>
|
|
1542
|
-
<div className="text-[var(--kyro-text-muted)] truncate hidden md:block">
|
|
1543
|
-
{typeof d.oldValue === "object"
|
|
1544
|
-
? JSON.stringify(d.oldValue)
|
|
1545
|
-
: String(d.oldValue ?? "null")}
|
|
1546
|
-
</div>
|
|
1547
|
-
<div className="md:col-span-2 text-[var(--kyro-text-primary)] truncate">
|
|
1548
|
-
<span className="md:hidden text-[var(--kyro-text-muted)]">→ </span>
|
|
1549
|
-
{typeof d.newValue === "object"
|
|
1550
|
-
? JSON.stringify(d.newValue)
|
|
1551
|
-
: String(d.newValue ?? "null")}
|
|
1552
|
-
</div>
|
|
1553
|
-
</div>
|
|
1554
|
-
))}
|
|
1555
|
-
</div>
|
|
1556
|
-
</div>
|
|
1557
|
-
)}
|
|
1558
|
-
|
|
1559
|
-
{loadingVersions ? (
|
|
1560
|
-
<div className="flex justify-center py-16">
|
|
1561
|
-
<span className="animate-spin text-[var(--kyro-primary)]">⌛</span>
|
|
1562
|
-
</div>
|
|
1563
|
-
) : versions.length === 0 ? (
|
|
1564
|
-
<div className="text-center py-16 text-[var(--kyro-text-muted)] text-sm italic">
|
|
1565
|
-
No versions yet.
|
|
1566
|
-
</div>
|
|
1567
|
-
) : (
|
|
1568
|
-
<div className="divide-y divide-[var(--kyro-border)]">
|
|
1569
|
-
{versions.map((v, i) => {
|
|
1570
|
-
const isSelected = compareSelected.includes(v.id);
|
|
1571
|
-
const isDraftVersion = v.status === "draft";
|
|
1572
|
-
const isAutoSaved = (v.changeDescription || "")
|
|
1573
|
-
.toLowerCase()
|
|
1574
|
-
.includes("auto");
|
|
1575
|
-
|
|
1576
|
-
return (
|
|
1577
|
-
<div
|
|
1578
|
-
key={v.id}
|
|
1579
|
-
onClick={
|
|
1580
|
-
compareMode ? () => toggleCompareSelection(v.id) : undefined
|
|
1581
|
-
}
|
|
1582
|
-
className={`transition-all ${compareMode
|
|
1583
|
-
? isSelected
|
|
1584
|
-
? "bg-[var(--kyro-primary)]/5 cursor-pointer"
|
|
1585
|
-
: "hover:bg-[var(--kyro-bg-secondary)] cursor-pointer"
|
|
1586
|
-
: "hover:bg-[var(--kyro-bg-secondary)]"
|
|
1587
|
-
}`}
|
|
1588
|
-
>
|
|
1589
|
-
{/* ── Desktop: grid row ─────────────────────────────── */}
|
|
1590
|
-
<div className="hidden md:grid grid-cols-12 gap-3 px-6 py-3 items-center">
|
|
1591
|
-
<div className="col-span-1 flex items-center gap-2">
|
|
1592
|
-
{compareMode ? (
|
|
1593
|
-
<div
|
|
1594
|
-
className={`w-4 h-4 rounded-full border ${isSelected
|
|
1595
|
-
? "border-[var(--kyro-primary)] bg-[var(--kyro-primary)]"
|
|
1596
|
-
: "border-[var(--kyro-border)]"
|
|
1597
|
-
}`}
|
|
1598
|
-
>
|
|
1599
|
-
{isSelected && (
|
|
1600
|
-
<Check className="w-4 h-4" />
|
|
1601
|
-
)}
|
|
1602
|
-
</div>
|
|
1603
|
-
) : (
|
|
1604
|
-
<span className="text-[10px] font-bold text-[var(--kyro-text-muted)] w-5">
|
|
1605
|
-
{versions.length - i}
|
|
1606
|
-
</span>
|
|
1607
|
-
)}
|
|
1608
|
-
</div>
|
|
1609
|
-
<div className="col-span-4 min-w-0">
|
|
1610
|
-
<div className="text-[13px] font-medium text-[var(--kyro-text-primary)] truncate flex items-center gap-2">
|
|
1611
|
-
{v.changeDescription || "Snapshot"}
|
|
1612
|
-
{isAutoSaved && (
|
|
1613
|
-
<span className="text-[9px] px-1.5 py-0.5 bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-secondary)] rounded font-bold tracking-wider">
|
|
1614
|
-
Auto
|
|
1615
|
-
</span>
|
|
1616
|
-
)}
|
|
1617
|
-
</div>
|
|
1618
|
-
<div className="text-[11px] text-[var(--kyro-text-muted)]">
|
|
1619
|
-
{new Date(v.createdAt as string).toLocaleString("en-US", {
|
|
1620
|
-
month: "short",
|
|
1621
|
-
day: "numeric",
|
|
1622
|
-
hour: "2-digit",
|
|
1623
|
-
minute: "2-digit",
|
|
1624
|
-
})}
|
|
1625
|
-
</div>
|
|
1626
|
-
</div>
|
|
1627
|
-
<div className="col-span-3">
|
|
1628
|
-
{v.status && (
|
|
1629
|
-
<span
|
|
1630
|
-
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold capitalize tracking-wider ${v.status === "published"
|
|
1631
|
-
? " text-[var(--kyro-success)]"
|
|
1632
|
-
: " text-[var(--kyro-warning)]"
|
|
1633
|
-
}`}
|
|
1634
|
-
>
|
|
1635
|
-
<span
|
|
1636
|
-
className={`w-1.5 h-1.5 rounded-full ${v.status === "published" ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-warning)]"}`}
|
|
1637
|
-
/>
|
|
1638
|
-
{v.status}
|
|
1639
|
-
</span>
|
|
1640
|
-
)}
|
|
1641
|
-
</div>
|
|
1642
|
-
<div className="col-span-2 text-[11px] text-[var(--kyro-text-muted)]">
|
|
1643
|
-
{v.createdBy || "system"}
|
|
1644
|
-
</div>
|
|
1645
|
-
<div className="col-span-2 flex justify-end">
|
|
1646
|
-
{!compareMode && (
|
|
1647
|
-
<button
|
|
1648
|
-
type="button"
|
|
1649
|
-
onClick={() => handleRestoreVersion(v.id)}
|
|
1650
|
-
className="px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[11px] font-bold tracking-wider text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] transition-all active:scale-95"
|
|
1651
|
-
>
|
|
1652
|
-
Restore
|
|
1653
|
-
</button>
|
|
1654
|
-
)}
|
|
1655
|
-
</div>
|
|
1656
|
-
</div>
|
|
1657
|
-
|
|
1658
|
-
{/* ── Mobile: card layout ───────────────────────────── */}
|
|
1659
|
-
<div className="md:hidden flex items-start gap-3 px-4 py-3">
|
|
1660
|
-
{/* Left: index or compare checkbox */}
|
|
1661
|
-
<div className="pt-0.5 shrink-0">
|
|
1662
|
-
{compareMode ? (
|
|
1663
|
-
<div
|
|
1664
|
-
className={`w-4 h-4 rounded-full border ${isSelected
|
|
1665
|
-
? "border-[var(--kyro-primary)] bg-[var(--kyro-primary)]"
|
|
1666
|
-
: "border-[var(--kyro-border)]"
|
|
1667
|
-
}`}
|
|
1668
|
-
>
|
|
1669
|
-
{isSelected && <Check className="w-4 h-4" />}
|
|
1670
|
-
</div>
|
|
1671
|
-
) : (
|
|
1672
|
-
<span className="text-[10px] font-bold text-[var(--kyro-text-muted)] w-5 inline-block text-center">
|
|
1673
|
-
{versions.length - i}
|
|
1674
|
-
</span>
|
|
1675
|
-
)}
|
|
1676
|
-
</div>
|
|
1677
|
-
|
|
1678
|
-
{/* Center: description, date, status */}
|
|
1679
|
-
<div className="flex-1 min-w-0">
|
|
1680
|
-
<div className="text-[13px] font-medium text-[var(--kyro-text-primary)] truncate flex items-center gap-1.5">
|
|
1681
|
-
{v.changeDescription || "Snapshot"}
|
|
1682
|
-
{isAutoSaved && (
|
|
1683
|
-
<span className="text-[9px] px-1 py-0.5 bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-secondary)] rounded font-bold tracking-wider shrink-0">
|
|
1684
|
-
Auto
|
|
1685
|
-
</span>
|
|
1686
|
-
)}
|
|
1687
|
-
</div>
|
|
1688
|
-
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
1689
|
-
<span className="text-[11px] text-[var(--kyro-text-muted)]">
|
|
1690
|
-
{new Date(v.createdAt as string).toLocaleString("en-US", {
|
|
1691
|
-
month: "short",
|
|
1692
|
-
day: "numeric",
|
|
1693
|
-
hour: "2-digit",
|
|
1694
|
-
minute: "2-digit",
|
|
1695
|
-
})}
|
|
1696
|
-
</span>
|
|
1697
|
-
{v.status && (
|
|
1698
|
-
<span
|
|
1699
|
-
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold capitalize tracking-wider ${v.status === "published"
|
|
1700
|
-
? "text-[var(--kyro-success)]"
|
|
1701
|
-
: "text-[var(--kyro-warning)]"
|
|
1702
|
-
}`}
|
|
1703
|
-
>
|
|
1704
|
-
<span
|
|
1705
|
-
className={`w-1 h-1 rounded-full ${v.status === "published" ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-warning)]"}`}
|
|
1706
|
-
/>
|
|
1707
|
-
{v.status}
|
|
1708
|
-
</span>
|
|
1709
|
-
)}
|
|
1710
|
-
<span className="text-[10px] text-[var(--kyro-text-muted)] opacity-60">
|
|
1711
|
-
{v.createdBy || "system"}
|
|
1712
|
-
</span>
|
|
1713
|
-
</div>
|
|
1714
|
-
</div>
|
|
1715
|
-
|
|
1716
|
-
{/* Right: restore button */}
|
|
1717
|
-
{!compareMode && (
|
|
1718
|
-
<button
|
|
1719
|
-
type="button"
|
|
1720
|
-
onClick={() => handleRestoreVersion(v.id)}
|
|
1721
|
-
className="shrink-0 px-2.5 py-1 rounded-lg border border-[var(--kyro-border)] text-[10px] font-bold tracking-wider text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] transition-all active:scale-95"
|
|
1722
|
-
>
|
|
1723
|
-
Restore
|
|
1724
|
-
</button>
|
|
1725
|
-
)}
|
|
1726
|
-
</div>
|
|
1727
|
-
</div>
|
|
1728
|
-
);
|
|
1729
|
-
})}
|
|
1730
|
-
</div>
|
|
1731
|
-
)}
|
|
1732
|
-
</div>
|
|
1733
|
-
</div>
|
|
1734
|
-
);
|
|
1735
|
-
|
|
1736
|
-
const renderApiView = () => (
|
|
1737
|
-
<div className="w-full space-y-8 animate-in fade-in slide-in-from-bottom-4">
|
|
1738
|
-
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-8">
|
|
1739
|
-
<div className="surface-tile p-8 min-w-0">
|
|
1740
|
-
<h2 className="text-xl font-bold mb-6">Response Payload</h2>
|
|
1741
|
-
<div className="bg-[#0f172a] p-6 rounded-2xl border border-white/5 overflow-x-auto max-h-[800px]">
|
|
1742
|
-
<pre className="text-blue-300 text-xs font-mono whitespace-pre-wrap break-all">
|
|
1743
|
-
{JSON.stringify(formData, null, 2)}
|
|
1744
|
-
</pre>
|
|
1745
|
-
</div>
|
|
1746
|
-
</div>
|
|
1747
|
-
|
|
1748
|
-
<div className="space-y-6">
|
|
1749
|
-
<div className="surface-tile p-8 space-y-6">
|
|
1750
|
-
<h2 className="text-xl font-bold mb-6">API Info</h2>
|
|
1751
|
-
|
|
1752
|
-
<div className="space-y-6">
|
|
1753
|
-
<div>
|
|
1754
|
-
<label className="text-[10px] font-bold tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
|
|
1755
|
-
Reference Path
|
|
1756
|
-
</label>
|
|
1757
|
-
<div className="relative group">
|
|
1758
|
-
<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">
|
|
1759
|
-
{`/api/${collectionSlug}/${formData.id || ""}`}
|
|
1760
|
-
</code>
|
|
1761
|
-
</div>
|
|
1762
|
-
</div>
|
|
1763
|
-
|
|
1764
|
-
<div>
|
|
1765
|
-
<label className="text-[10px] font-bold tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-3">
|
|
1766
|
-
Methods Allowed
|
|
1767
|
-
</label>
|
|
1768
|
-
<div className="flex gap-2">
|
|
1769
|
-
<span className="px-3 py-1.5 bg-green-500/10 text-green-500 rounded-lg font-bold text-[9px] tracking-wider">
|
|
1770
|
-
GET
|
|
1771
|
-
</span>
|
|
1772
|
-
<span className="px-3 py-1.5 bg-amber-500/10 text-amber-500 rounded-lg font-bold text-[9px] tracking-wider">
|
|
1773
|
-
PATCH
|
|
1774
|
-
</span>
|
|
1775
|
-
<span className="px-3 py-1.5 bg-red-500/10 text-red-500 rounded-lg font-bold text-[9px] tracking-wider">
|
|
1776
|
-
DELETE
|
|
1777
|
-
</span>
|
|
1778
|
-
</div>
|
|
1779
|
-
</div>
|
|
1780
|
-
|
|
1781
|
-
<div className="pt-6 border-t border-[var(--kyro-border)]">
|
|
1782
|
-
<label className="text-[10px] font-bold tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-4">
|
|
1783
|
-
Security Policy
|
|
1784
|
-
</label>
|
|
1785
|
-
<div className="space-y-3">
|
|
1786
|
-
{[
|
|
1787
|
-
{
|
|
1788
|
-
id: "auth-required",
|
|
1789
|
-
label: "Authorization required",
|
|
1790
|
-
checked: true,
|
|
1791
|
-
},
|
|
1792
|
-
{
|
|
1793
|
-
id: "auth-admin",
|
|
1794
|
-
label: "System administrator only",
|
|
1795
|
-
checked: false,
|
|
1796
|
-
},
|
|
1797
|
-
{
|
|
1798
|
-
id: "auth-api",
|
|
1799
|
-
label: "API Key authentication allowed",
|
|
1800
|
-
checked: true,
|
|
1801
|
-
},
|
|
1802
|
-
].map((item) => (
|
|
1803
|
-
<label
|
|
1804
|
-
key={item.id}
|
|
1805
|
-
className="flex items-center gap-3 cursor-pointer group"
|
|
1806
|
-
>
|
|
1807
|
-
<div
|
|
1808
|
-
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)]"}`}
|
|
1809
|
-
>
|
|
1810
|
-
{item.checked && (
|
|
1811
|
-
<Check className="w-4 h-4" />
|
|
1812
|
-
)}
|
|
1813
|
-
</div>
|
|
1814
|
-
<span className="text-xs font-medium text-[var(--kyro-text-secondary)] group-hover:text-[var(--kyro-text-primary)] transition-colors">
|
|
1815
|
-
{item.label}
|
|
1816
|
-
</span>
|
|
1817
|
-
</label>
|
|
1818
|
-
))}
|
|
1819
|
-
</div>
|
|
1820
|
-
</div>
|
|
1821
|
-
|
|
1822
|
-
<div className="pt-6 border-t border-[var(--kyro-border)]">
|
|
1823
|
-
<label className="text-[10px] font-bold tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
|
|
1824
|
-
Usage Help
|
|
1825
|
-
</label>
|
|
1826
|
-
<p className="text-[11px] text-[var(--kyro-text-secondary)] leading-relaxed">
|
|
1827
|
-
Include the{" "}
|
|
1828
|
-
<code className="text-[var(--kyro-text-primary)] font-bold">
|
|
1829
|
-
Authorization: Bearer <token>
|
|
1830
|
-
</code>{" "}
|
|
1831
|
-
header to perform write operations on this document.
|
|
1832
|
-
</p>
|
|
1833
|
-
</div>
|
|
1834
|
-
</div>
|
|
1835
|
-
</div>
|
|
1836
|
-
</div>
|
|
1837
|
-
</div>
|
|
1838
|
-
</div>
|
|
1839
|
-
);
|
|
1840
|
-
|
|
1841
936
|
if (clientLoading) {
|
|
1842
937
|
return (
|
|
1843
938
|
<div className="space-y-6 p-4">
|
|
@@ -1852,9 +947,45 @@ export function AutoForm({
|
|
|
1852
947
|
);
|
|
1853
948
|
}
|
|
1854
949
|
|
|
950
|
+
if (fetchError) {
|
|
951
|
+
return (
|
|
952
|
+
<div className="flex flex-col items-center justify-center gap-4 p-16">
|
|
953
|
+
<AlertTriangle className="w-8 h-8 text-[var(--kyro-danger)]" />
|
|
954
|
+
<p className="text-sm text-[var(--kyro-text-secondary)]">
|
|
955
|
+
Failed to load document. Check your connection.
|
|
956
|
+
</p>
|
|
957
|
+
<button
|
|
958
|
+
type="button"
|
|
959
|
+
onClick={() => {
|
|
960
|
+
setFetchError(false);
|
|
961
|
+
setClientLoading(true);
|
|
962
|
+
setRetryTick((n) => n + 1);
|
|
963
|
+
}}
|
|
964
|
+
className="kyro-btn kyro-btn-primary px-6 py-2 rounded-xl text-sm font-bold"
|
|
965
|
+
>
|
|
966
|
+
Retry
|
|
967
|
+
</button>
|
|
968
|
+
</div>
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
1855
972
|
return (
|
|
1856
973
|
<div className="flex flex-col h-full">
|
|
1857
|
-
{layout !== "single" &&
|
|
974
|
+
{layout !== "single" && (
|
|
975
|
+
<AutoFormHeader
|
|
976
|
+
collectionSlug={collectionSlug}
|
|
977
|
+
globalSlug={globalSlug}
|
|
978
|
+
documentStatus={documentStatus || "draft"}
|
|
979
|
+
hasUnpublishedChanges={hasUnpublishedChanges}
|
|
980
|
+
localSaveStatus={localSaveStatus}
|
|
981
|
+
handleCreateNew={handleCreateNew}
|
|
982
|
+
handleDuplicate={handleDuplicate}
|
|
983
|
+
handleUnpublish={handleUnpublish}
|
|
984
|
+
handleDelete={handleDelete}
|
|
985
|
+
handlePublish={handlePublish}
|
|
986
|
+
handleSchedulePublish={handleSchedulePublish}
|
|
987
|
+
/>
|
|
988
|
+
)}
|
|
1858
989
|
{layout === "single" && (
|
|
1859
990
|
<>
|
|
1860
991
|
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
|
|
@@ -1909,7 +1040,7 @@ export function AutoForm({
|
|
|
1909
1040
|
style={{ width: 0, height: 0, opacity: 0, padding: 0, margin: 0, border: 'none', position: 'absolute' }}
|
|
1910
1041
|
onClick={async () => {
|
|
1911
1042
|
try {
|
|
1912
|
-
const response = await saveDocument();
|
|
1043
|
+
const response = await saveDocument(formData);
|
|
1913
1044
|
if (response.ok) {
|
|
1914
1045
|
const result = await response.json();
|
|
1915
1046
|
const savedData = result.data || formData;
|
|
@@ -1926,225 +1057,32 @@ export function AutoForm({
|
|
|
1926
1057
|
</>
|
|
1927
1058
|
)}
|
|
1928
1059
|
<main className="w-full pt-6 md:pt-0">
|
|
1929
|
-
{view === "edit" &&
|
|
1930
|
-
|
|
1931
|
-
|
|
1060
|
+
{view === "edit" && (
|
|
1061
|
+
<AutoFormEditView
|
|
1062
|
+
config={config}
|
|
1063
|
+
layout={layout}
|
|
1064
|
+
collectionSlug={collectionSlug}
|
|
1065
|
+
renderField={renderField}
|
|
1066
|
+
/>
|
|
1067
|
+
)}
|
|
1068
|
+
{view === "version" && (
|
|
1069
|
+
<AutoFormVersionView
|
|
1070
|
+
handleRestoreVersion={handleRestoreVersion}
|
|
1071
|
+
handleCompareVersions={handleCompareVersions}
|
|
1072
|
+
toggleCompareSelection={toggleCompareSelection}
|
|
1073
|
+
/>
|
|
1074
|
+
)}
|
|
1075
|
+
{view === "api" && (
|
|
1076
|
+
<AutoFormApiView
|
|
1077
|
+
collectionSlug={collectionSlug}
|
|
1078
|
+
globalSlug={globalSlug}
|
|
1079
|
+
/>
|
|
1080
|
+
)}
|
|
1932
1081
|
</main>
|
|
1933
1082
|
</div>
|
|
1934
1083
|
);
|
|
1935
1084
|
}
|
|
1936
1085
|
|
|
1937
|
-
interface RelationshipFieldProps {
|
|
1938
|
-
field: Field;
|
|
1939
|
-
value?: unknown;
|
|
1940
|
-
onChange?: (value: unknown) => void;
|
|
1941
|
-
disabled?: boolean;
|
|
1942
|
-
error?: string;
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
|
-
function RelationshipField({
|
|
1946
|
-
field,
|
|
1947
|
-
value,
|
|
1948
|
-
onChange,
|
|
1949
|
-
disabled,
|
|
1950
|
-
error,
|
|
1951
|
-
}: RelationshipFieldProps) {
|
|
1952
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
1953
|
-
const [search, setSearch] = useState("");
|
|
1954
|
-
const [options, setOptions] = useState<unknown[]>([]);
|
|
1955
|
-
const [loading, setLoading] = useState(false);
|
|
1956
|
-
|
|
1957
|
-
const isMultiple = field.hasMany;
|
|
1958
|
-
const targetCollection = Array.isArray(field.relationTo)
|
|
1959
|
-
? field.relationTo[0]
|
|
1960
|
-
: field.relationTo;
|
|
1961
|
-
|
|
1962
|
-
const fetchOptions = () => {
|
|
1963
|
-
setLoading(true);
|
|
1964
|
-
fetchWithAuth(`/api/${targetCollection}?limit=50`)
|
|
1965
|
-
.then((res) => res.json())
|
|
1966
|
-
.then((data) => {
|
|
1967
|
-
setOptions((data.docs || []) as unknown[]);
|
|
1968
|
-
setLoading(false);
|
|
1969
|
-
})
|
|
1970
|
-
.catch((err) => {
|
|
1971
|
-
console.error("Failed to fetch relations:", err);
|
|
1972
|
-
setLoading(false);
|
|
1973
|
-
});
|
|
1974
|
-
};
|
|
1975
|
-
|
|
1976
|
-
useEffect(() => {
|
|
1977
|
-
fetchOptions();
|
|
1978
|
-
}, [targetCollection]);
|
|
1979
|
-
|
|
1980
|
-
const getLabel = (opt: unknown) => {
|
|
1981
|
-
const o = opt as Record<string, unknown> | undefined;
|
|
1982
|
-
if (!o) return "";
|
|
1983
|
-
return String(
|
|
1984
|
-
o.title || o.name || o.label || o.filename || o.slug || o.id || "",
|
|
1985
|
-
);
|
|
1986
|
-
};
|
|
1987
|
-
|
|
1988
|
-
const findOptionById = (id: unknown) => {
|
|
1989
|
-
return (options as Array<Record<string, unknown>>).find(
|
|
1990
|
-
(o) => o.id === id,
|
|
1991
|
-
);
|
|
1992
|
-
};
|
|
1993
|
-
|
|
1994
|
-
const isSelected = (optId: string) => {
|
|
1995
|
-
if (!value) return false;
|
|
1996
|
-
if (isMultiple) {
|
|
1997
|
-
const arr = Array.isArray(value) ? value : [];
|
|
1998
|
-
return (arr as Array<{ id?: string }>).some((v) => (v.id || v) === optId);
|
|
1999
|
-
}
|
|
2000
|
-
return ((value as { id?: string })?.id || value) === optId;
|
|
2001
|
-
};
|
|
2002
|
-
|
|
2003
|
-
const toggleSelection = (opt: { id?: string }) => {
|
|
2004
|
-
if (isMultiple) {
|
|
2005
|
-
const current = Array.isArray(value) ? value : [];
|
|
2006
|
-
const arr = current as Array<{ id?: string }>;
|
|
2007
|
-
if (isSelected(opt.id as string)) {
|
|
2008
|
-
onChange?.(arr.filter((item) => (item.id || item) !== opt.id));
|
|
2009
|
-
} else {
|
|
2010
|
-
onChange?.([...arr, opt.id]);
|
|
2011
|
-
}
|
|
2012
|
-
} else {
|
|
2013
|
-
if (isSelected(opt.id as string)) {
|
|
2014
|
-
onChange?.(null);
|
|
2015
|
-
} else {
|
|
2016
|
-
onChange?.(opt.id);
|
|
2017
|
-
setIsOpen(false);
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
};
|
|
2021
|
-
|
|
2022
|
-
const renderSelectedValue = () => {
|
|
2023
|
-
if (!value) return null;
|
|
2024
|
-
if (isMultiple && Array.isArray(value)) {
|
|
2025
|
-
if (value.length === 0) return "None selected";
|
|
2026
|
-
const arr = value as Array<{ id?: string }>;
|
|
2027
|
-
return arr
|
|
2028
|
-
.map((v) => {
|
|
2029
|
-
const id = v.id || v;
|
|
2030
|
-
const opt = findOptionById(id);
|
|
2031
|
-
return opt ? getLabel(opt) : String(id);
|
|
2032
|
-
})
|
|
2033
|
-
.join(", ");
|
|
2034
|
-
}
|
|
2035
|
-
const id = (value as { id?: string }).id || value;
|
|
2036
|
-
const opt = findOptionById(id);
|
|
2037
|
-
return opt ? getLabel(opt) : String(id);
|
|
2038
|
-
};
|
|
2039
|
-
|
|
2040
|
-
const filteredOptions = (search
|
|
2041
|
-
? (options || []).filter((opt) => {
|
|
2042
|
-
const o = opt as Record<string, unknown>;
|
|
2043
|
-
const term = search.toLowerCase();
|
|
2044
|
-
const searchableFields = ["title", "name", "label", "filename", "slug"];
|
|
2045
|
-
return searchableFields.some(
|
|
2046
|
-
(key) => o[key] && String(o[key]).toLowerCase().includes(term),
|
|
2047
|
-
);
|
|
2048
|
-
})
|
|
2049
|
-
: options || []) as Array<Record<string, unknown>>;
|
|
2050
|
-
|
|
2051
|
-
return (
|
|
2052
|
-
<div className="kyro-form-field">
|
|
2053
|
-
<label className="kyro-form-label">
|
|
2054
|
-
{field.label || field.name}
|
|
2055
|
-
{field.required && <span className="kyro-form-label-required">*</span>}
|
|
2056
|
-
</label>
|
|
2057
|
-
|
|
2058
|
-
<div
|
|
2059
|
-
className="kyro-form-relationship"
|
|
2060
|
-
onClick={() => !disabled && setIsOpen(true)}
|
|
2061
|
-
style={disabled ? { opacity: 0.5, cursor: "not-allowed" } : {}}
|
|
2062
|
-
>
|
|
2063
|
-
<div className="kyro-form-relationship-header">
|
|
2064
|
-
<span className="kyro-form-relationship-type">
|
|
2065
|
-
{targetCollection}
|
|
2066
|
-
</span>
|
|
2067
|
-
{isMultiple && (
|
|
2068
|
-
<span className="kyro-form-relationship-badge">Multiple</span>
|
|
2069
|
-
)}
|
|
2070
|
-
</div>
|
|
2071
|
-
|
|
2072
|
-
<div className="kyro-form-relationship-value">
|
|
2073
|
-
{value ? (
|
|
2074
|
-
renderSelectedValue()
|
|
2075
|
-
) : (
|
|
2076
|
-
<span className="kyro-form-relationship-empty">
|
|
2077
|
-
Click to search and select...
|
|
2078
|
-
</span>
|
|
2079
|
-
)}
|
|
2080
|
-
</div>
|
|
2081
|
-
</div>
|
|
2082
|
-
|
|
2083
|
-
{(field.admin?.description && !error) ? (
|
|
2084
|
-
<p className="kyro-form-help">{String((field.admin as { description?: string }).description)}</p>
|
|
2085
|
-
) : null}
|
|
2086
|
-
{error && <p className="kyro-form-error">{error}</p>}
|
|
2087
|
-
|
|
2088
|
-
{/* Modal */}
|
|
2089
|
-
{isOpen && (
|
|
2090
|
-
<div className="kyro-modal-overlay" onClick={() => setIsOpen(false)}>
|
|
2091
|
-
<div
|
|
2092
|
-
className="kyro-relation-modal"
|
|
2093
|
-
onClick={(e) => e.stopPropagation()}
|
|
2094
|
-
>
|
|
2095
|
-
<div className="kyro-relation-modal-header">
|
|
2096
|
-
<h3>Select {field.label || field.name}</h3>
|
|
2097
|
-
<input
|
|
2098
|
-
type="text"
|
|
2099
|
-
autoFocus
|
|
2100
|
-
placeholder={`Search in ${targetCollection}...`}
|
|
2101
|
-
className="kyro-relation-modal-search"
|
|
2102
|
-
value={search}
|
|
2103
|
-
onChange={(e) => setSearch(e.target.value)}
|
|
2104
|
-
/>
|
|
2105
|
-
</div>
|
|
2106
|
-
|
|
2107
|
-
<div className="kyro-relation-modal-list">
|
|
2108
|
-
{loading ? (
|
|
2109
|
-
<div className="kyro-relation-modal-empty">Loading...</div>
|
|
2110
|
-
) : filteredOptions.length === 0 ? (
|
|
2111
|
-
<EmptyState title="No results found." />
|
|
2112
|
-
) : (
|
|
2113
|
-
filteredOptions.map((opt) => {
|
|
2114
|
-
const o = opt as { id?: string };
|
|
2115
|
-
return (
|
|
2116
|
-
<button
|
|
2117
|
-
key={String(o.id)}
|
|
2118
|
-
type="button"
|
|
2119
|
-
className={`kyro-relation-modal-item ${isSelected(String(o.id)) ? "selected" : ""}`}
|
|
2120
|
-
onClick={() => toggleSelection(o)}
|
|
2121
|
-
>
|
|
2122
|
-
<span>{getLabel(opt)}</span>
|
|
2123
|
-
<span className="kyro-relation-modal-item-id">
|
|
2124
|
-
{o.id ? `(${String(o.id).slice(0, 8)}...)` : ""}
|
|
2125
|
-
</span>
|
|
2126
|
-
</button>
|
|
2127
|
-
);
|
|
2128
|
-
})
|
|
2129
|
-
)}
|
|
2130
|
-
</div>
|
|
2131
|
-
|
|
2132
|
-
<div className="kyro-relation-modal-footer">
|
|
2133
|
-
<button
|
|
2134
|
-
type="button"
|
|
2135
|
-
className="kyro-btn kyro-btn-secondary kyro-btn-sm"
|
|
2136
|
-
onClick={() => setIsOpen(false)}
|
|
2137
|
-
>
|
|
2138
|
-
Done
|
|
2139
|
-
</button>
|
|
2140
|
-
</div>
|
|
2141
|
-
</div>
|
|
2142
|
-
</div>
|
|
2143
|
-
)}
|
|
2144
|
-
</div>
|
|
2145
|
-
);
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
1086
|
// SEO Utilities
|
|
2149
1087
|
function stripHtml(html: string) {
|
|
2150
1088
|
if (typeof html !== "string") return "";
|