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