@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.
@@ -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
- // Resolve the "live" config to preserve functions (admin.condition) lost during prop serialization
76
- const activeConfig = globalSlug
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
- : propConfig;
81
- const config = activeConfig || propConfig;
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 when SSR didn't provide data
178
+ // Client-side fetch with cache, stale-while-revalidate, and abort
152
179
  useEffect(() => {
153
- const shouldFetchCollection = collectionSlug && documentId && documentId !== "new";
154
- const shouldFetchGlobal = globalSlug;
155
- if (!shouldFetchCollection && !shouldFetchGlobal) return;
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
- setClientLoading(true);
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
- setFormData(docData);
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
- }, [collectionSlug, documentId, globalSlug, initialData]);
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
- location.reload();
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 &lt;token&gt;
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" && renderHeader()}
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" && renderEditView()}
1926
- {view === "version" && renderVersionView()}
1927
- {view === "api" && renderApiView()}
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 "";