@kyro-cms/admin 0.10.6 → 0.10.7

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