@kyro-cms/admin 0.11.3 → 0.11.5

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.
@@ -5,7 +5,6 @@ import { Spinner } from "./ui/Spinner";
5
5
  import { Shimmer } from "./ui/Shimmer";
6
6
  import { SlidePanel } from "./ui/SlidePanel";
7
7
  import { Modal } from "./ui/Modal";
8
- import { Pagination } from "./ui/Pagination";
9
8
  import { Badge } from "./ui/Badge";
10
9
  import { Folder } from "./ui/icons";
11
10
 
@@ -26,13 +25,7 @@ import {
26
25
  Archive,
27
26
  } from "./ui/icons";
28
27
  import { PromptModal } from "./ui/PromptModal";
29
- import ReactCrop, {
30
- type Crop,
31
- centerCrop,
32
- makeAspectCrop,
33
- convertToPixelCrop,
34
- } from "react-image-crop";
35
- import "react-image-crop/dist/ReactCrop.css";
28
+ import { ImageFocalEditor } from "./ui/ImageFocalEditor";
36
29
  import {
37
30
  apiGet,
38
31
  apiPost,
@@ -56,6 +49,11 @@ interface MediaItem {
56
49
  folder?: string;
57
50
  alt?: string;
58
51
  caption?: string;
52
+ metadata?: {
53
+ crop?: { x: number; y: number; width: number; height: number };
54
+ hotspot?: { x: number; y: number; width: number; height: number };
55
+ [key: string]: any;
56
+ };
59
57
  createdAt: string;
60
58
  updatedAt?: string;
61
59
  }
@@ -133,6 +131,7 @@ export function MediaGallery({
133
131
 
134
132
  const [items, setItems] = useState<MediaItem[]>([]);
135
133
  const [loading, setLoading] = useState(true);
134
+ const [loadingMore, setLoadingMore] = useState(false);
136
135
  const [folders, setFolders] = useState<string[]>([]);
137
136
  const [currentFolder, setCurrentFolder] = useState<string>("");
138
137
  const [search, setSearch] = useState("");
@@ -160,14 +159,14 @@ export function MediaGallery({
160
159
  const [isDragging, setIsDragging] = useState(false);
161
160
 
162
161
  const fileInputRef = useRef<HTMLInputElement>(null);
163
- const [crop, setCrop] = useState<Crop>();
164
- const imgRef = useRef<HTMLImageElement>(null);
165
162
 
166
- const loadMedia = useCallback(async () => {
167
- setLoading(true);
163
+ const loadMedia = useCallback(async (pageNum: number) => {
164
+ if (pageNum === 1) setLoading(true);
165
+ else setLoadingMore(true);
166
+
168
167
  try {
169
168
  const params = new URLSearchParams({
170
- page: page.toString(),
169
+ page: pageNum.toString(),
171
170
  limit: limit.toString(),
172
171
  });
173
172
  if (currentFolder) params.append("folder", currentFolder);
@@ -175,18 +174,21 @@ export function MediaGallery({
175
174
  if (filter !== "all") params.append("type", filter);
176
175
 
177
176
  const result = await apiGet(withCacheBust(`/api/media?${params}`));
178
- setItems((result.docs || []).map((doc: Record<string, unknown>) => ({
177
+ const newItems = (result.docs || []).map((doc: Record<string, unknown>) => ({
179
178
  ...doc,
180
- type: getFileType(doc.mimeType),
181
- })));
179
+ type: getFileType(doc.mimeType as string),
180
+ }));
181
+
182
+ setItems(prev => pageNum === 1 ? newItems : [...prev, ...newItems]);
182
183
  setTotal(result.totalDocs || 0);
183
184
  setTotalPages(result.totalPages || 1);
184
185
  } catch (error) {
185
186
  console.error("Failed to load media:", error);
186
187
  } finally {
187
188
  setLoading(false);
189
+ setLoadingMore(false);
188
190
  }
189
- }, [page, currentFolder, search, filter]);
191
+ }, [currentFolder, search, filter, limit]);
190
192
 
191
193
  const loadFolders = useCallback(async () => {
192
194
  try {
@@ -220,8 +222,16 @@ export function MediaGallery({
220
222
  }, [pickerMode, storageConfigured, storageChecked]);
221
223
 
222
224
  useEffect(() => {
223
- loadMedia();
224
- }, [loadMedia]);
225
+ setPage(1);
226
+ loadMedia(1);
227
+ }, [currentFolder, search, filter, loadMedia]);
228
+
229
+ const handleLoadMore = () => {
230
+ if (loadingMore || page >= totalPages) return;
231
+ const nextPage = page + 1;
232
+ setPage(nextPage);
233
+ loadMedia(nextPage);
234
+ };
225
235
 
226
236
  useEffect(() => {
227
237
  loadFolders();
@@ -271,7 +281,8 @@ export function MediaGallery({
271
281
 
272
282
  setUploading(false);
273
283
  setUploadProgress({});
274
- loadMedia();
284
+ setPage(1);
285
+ loadMedia(1);
275
286
  loadFolders();
276
287
  if (failCount > 0) {
277
288
  toast.error(`${failCount} file(s) failed to upload`);
@@ -292,7 +303,8 @@ export function MediaGallery({
292
303
  await apiDelete(`/api/media/${id}`);
293
304
  }
294
305
  setSelectedIds(new Set());
295
- loadMedia();
306
+ setPage(1);
307
+ loadMedia(1);
296
308
  toast.success(`${selectedIds.size} item(s) deleted`);
297
309
  } catch (error) {
298
310
  console.error("Bulk delete failed:", error);
@@ -337,7 +349,8 @@ export function MediaGallery({
337
349
  await apiDelete(`/api/media/folders?path=${encodeURIComponent(folder)}`);
338
350
  if (currentFolder === folder) setCurrentFolder("");
339
351
  loadFolders();
340
- loadMedia();
352
+ setPage(1);
353
+ loadMedia(1);
341
354
  toast.success(`Folder "${folder}" deleted`);
342
355
  } catch (error) {
343
356
  console.error("Failed to delete folder:", error);
@@ -361,66 +374,24 @@ export function MediaGallery({
361
374
  }
362
375
  };
363
376
 
364
- const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
365
- const { width, height } = e.currentTarget;
366
- const initialCrop = centerCrop(
367
- makeAspectCrop({ unit: "%", width: 90 }, 1, width, height),
368
- width,
369
- height,
370
- );
371
- setCrop(initialCrop);
372
- };
373
-
374
- const onCropComplete = async () => {
375
- if (!crop || !imgRef.current || !panelItem) return;
376
-
377
- setUploading(true);
377
+ const handleSaveCropHotspot = async (cropData: any, hotspotData: any) => {
378
+ if (!panelItem) return;
378
379
  try {
379
- const pixelCrop = convertToPixelCrop(
380
- crop,
381
- imgRef.current.width,
382
- imgRef.current.height,
383
- );
384
-
385
- const canvas = document.createElement("canvas");
386
- const scaleX = imgRef.current.naturalWidth / imgRef.current.width;
387
- const scaleY = imgRef.current.naturalHeight / imgRef.current.height;
388
-
389
- canvas.width = pixelCrop.width;
390
- canvas.height = pixelCrop.height;
391
- const ctx = canvas.getContext("2d");
392
-
393
- if (ctx) {
394
- ctx.drawImage(
395
- imgRef.current,
396
- pixelCrop.x * scaleX,
397
- pixelCrop.y * scaleY,
398
- pixelCrop.width * scaleX,
399
- pixelCrop.height * scaleY,
400
- 0,
401
- 0,
402
- pixelCrop.width,
403
- pixelCrop.height,
404
- );
405
-
406
- const blob = await new Promise<Blob | null>((resolve) =>
407
- canvas.toBlob(resolve, panelItem.mimeType),
408
- );
409
-
410
- if (blob) {
411
- const formData = new FormData();
412
- formData.append("file", blob, `cropped-${panelItem.filename}`);
413
- if (currentFolder) formData.append("folder", currentFolder);
414
-
415
- await apiUpload("/api/media", formData);
416
- loadMedia();
417
- setShowCrop(false);
418
- toast.success("Cropped image saved");
419
- }
420
- }
380
+ setUploading(true);
381
+ const metadata = {
382
+ ...panelItem.metadata,
383
+ crop: cropData,
384
+ hotspot: hotspotData
385
+ };
386
+
387
+ const result = await apiPatch(`/api/media/${panelItem.id}`, { metadata });
388
+ setItems(prev => prev.map(item => item.id === panelItem.id ? result.doc : item));
389
+ setPanelItem(result.doc);
390
+ setShowCrop(false);
391
+ toast.success("Focal metadata saved");
421
392
  } catch (err) {
422
- console.error("Crop failed:", err);
423
- toast.error("Crop failed");
393
+ console.error("Save failed:", err);
394
+ toast.error("Failed to save focal metadata");
424
395
  } finally {
425
396
  setUploading(false);
426
397
  }
@@ -598,7 +569,7 @@ export function MediaGallery({
598
569
  )}
599
570
 
600
571
  {/* Main Content Area */}
601
- <div className="flex-1 flex flex-col min-h-0 bg-[var(--kyro-bg)]">
572
+ <div className="flex-1 flex flex-col min-h-0 min-w-0 bg-[var(--kyro-bg)]">
602
573
  <div className={`flex-1 overflow-y-auto custom-scrollbar ${pickerMode ? "px-2 py-4" : "py-4 px-2 md:py-8 md:px-4"}`}>
603
574
  {loading ? (
604
575
  <div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
@@ -782,13 +753,26 @@ export function MediaGallery({
782
753
  </table>
783
754
  </div>
784
755
  )}
785
- </div>
786
756
 
787
- <Pagination
788
- page={page}
789
- totalPages={totalPages}
790
- onPageChange={setPage}
791
- />
757
+ {/* Load More Button */}
758
+ {!loading && page < totalPages && (
759
+ <div className="flex justify-center mt-8 pb-8">
760
+ <button
761
+ onClick={handleLoadMore}
762
+ disabled={loadingMore}
763
+ className="px-8 py-3 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-xs hover:scale-105 active:scale-95 transition-all shadow-xl disabled:opacity-50 flex items-center gap-2"
764
+ >
765
+ {loadingMore ? (
766
+ <>
767
+ <Spinner size="sm" /> Loading...
768
+ </>
769
+ ) : (
770
+ "Load More"
771
+ )}
772
+ </button>
773
+ </div>
774
+ )}
775
+ </div>
792
776
  </div>
793
777
  </div>
794
778
 
@@ -1191,7 +1175,7 @@ export function MediaGallery({
1191
1175
  </Modal>
1192
1176
  )}
1193
1177
 
1194
- {/* Crop Modal */}
1178
+ {/* Focal Editor Modal */}
1195
1179
  {!pickerMode && showCrop && panelItem && (
1196
1180
  <Modal
1197
1181
  open={showCrop}
@@ -1200,41 +1184,14 @@ export function MediaGallery({
1200
1184
  size="full"
1201
1185
  variant="lightbox"
1202
1186
  >
1203
- <div className="flex flex-col h-full p-8">
1204
- <div className="flex items-center justify-between mb-8">
1205
- <h3 className="text-white font-bold text-2xl tracking-tighter">
1206
- Crop Image
1207
- </h3>
1208
- <div className="flex gap-3">
1209
- <button
1210
- type="button"
1211
- onClick={() => setShowCrop(false)}
1212
- className="px-4 py-2 border border-white/20 text-white/80 hover:bg-white/10 rounded-lg font-bold text-sm transition-colors"
1213
- >
1214
- Cancel
1215
- </button>
1216
- <button
1217
- type="button"
1218
- disabled={uploading}
1219
- onClick={onCropComplete}
1220
- className="px-4 py-2 bg-[var(--kyro-sidebar-active)] hover:opacity-90 text-[var(--kyro-sidebar-text-active)] rounded-lg font-bold text-sm transition-colors"
1221
- >
1222
- {uploading ? "Saving..." : "Save Crop"}
1223
- </button>
1224
- </div>
1225
- </div>
1226
- <div className="flex-1 w-full flex items-center justify-center overflow-auto">
1227
- <ReactCrop crop={crop} onChange={(c) => setCrop(c)}>
1228
- <img
1229
- ref={imgRef}
1230
- src={panelItem.url}
1231
- alt="Crop preview"
1232
- className="max-h-[70vh] object-contain"
1233
- onLoad={onImageLoad}
1234
- />
1235
- </ReactCrop>
1236
- </div>
1237
- </div>
1187
+ <ImageFocalEditor
1188
+ url={getAbsoluteUrl(panelItem.url)}
1189
+ initialCrop={panelItem.metadata?.crop}
1190
+ initialHotspot={panelItem.metadata?.hotspot}
1191
+ onSave={handleSaveCropHotspot}
1192
+ onCancel={() => setShowCrop(false)}
1193
+ isSaving={uploading}
1194
+ />
1238
1195
  </Modal>
1239
1196
  )}
1240
1197
  {!pickerMode && (
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
2
2
  import { apiGet, apiDelete } from "../lib/api";
3
3
  import { Shield, Monitor, Trash2, Clock, AlertTriangle, Info, LogOut, Globe, Activity, RefreshCcw, Smartphone, Laptop } from "./ui/icons";
4
4
  import { PageHeader } from "./ui/PageHeader";
5
+ import toast from "react-hot-toast";
5
6
  import { Badge } from "./ui/Badge";
6
7
 
7
8
  interface Session {
@@ -53,8 +54,10 @@ export function SessionsManager() {
53
54
  try {
54
55
  await apiDelete(`/api/auth/sessions/${id}`);
55
56
  setSessions((p) => p.filter((s) => s.id !== id));
57
+ toast.success("Session revoked");
56
58
  } catch {
57
59
  setError("Failed to revoke session");
60
+ toast.error("Failed to revoke session");
58
61
  } finally {
59
62
  setRevokingId(null);
60
63
  }
@@ -65,8 +68,10 @@ export function SessionsManager() {
65
68
  try {
66
69
  await apiDelete("/api/auth/sessions");
67
70
  setSessions((p) => p.filter((s) => s.currentSession));
71
+ toast.success("All other sessions revoked");
68
72
  } catch {
69
73
  setError("Failed to revoke sessions");
74
+ toast.error("Failed to revoke sessions");
70
75
  } finally {
71
76
  setRevokingAll(false);
72
77
  }
@@ -2,7 +2,7 @@
2
2
  import "../styles/main.css";
3
3
  import { nonAuthCollections } from "../lib/config";
4
4
  import { adminPath } from "../lib/paths";
5
- import { getSiteSettings } from "../lib/globals";
5
+ import { getSiteSettings, getBrandSettings } from "../lib/globals";
6
6
  import * as Icons from "lucide-react";
7
7
  import { UserMenu } from "./UserMenu";
8
8
 
@@ -13,11 +13,14 @@ interface NavItem {
13
13
  }
14
14
 
15
15
  const siteSettings = await getSiteSettings({ request: Astro.request });
16
- const siteName = siteSettings?.siteName || "KYRO.";
17
- const siteLogo = siteSettings?.siteLogo;
18
- const logoWidth = siteSettings?.logo?.width;
19
- const logoHeight = siteSettings?.logo?.height;
20
- const logoAlt = siteSettings?.logo?.altText || siteName;
16
+ const brandSettings = await getBrandSettings({ request: Astro.request });
17
+
18
+ const siteName = siteSettings?.siteName || brandSettings?.companyInfo?.companyName || "KYRO.";
19
+ const siteLogo = brandSettings?.identity?.primaryLogo;
20
+ const darkLogo = brandSettings?.identity?.darkLogo;
21
+ const logoWidth = siteLogo?.width || darkLogo?.width;
22
+ const logoHeight = siteLogo?.height || darkLogo?.height;
23
+ const logoAlt = siteLogo?.altText || darkLogo?.altText || siteName;
21
24
 
22
25
  interface Props {
23
26
  title: string;
@@ -97,17 +100,33 @@ function isActive(item: NavItem): boolean {
97
100
  >
98
101
  <div class="px-6 md:px-8 py-6 md:py-8 flex items-center justify-between gap-3">
99
102
  {
100
- siteLogo ? (
101
- <img
102
- src={siteLogo.url}
103
- alt={logoAlt}
104
- style={{
105
- width: logoWidth ? `${logoWidth}px` : "auto",
106
- height: logoHeight ? `${logoHeight}px` : "32px",
107
- objectFit: "contain",
108
- }}
109
- class="rounded-lg"
110
- />
103
+ siteLogo || darkLogo ? (
104
+ <div class="flex items-center">
105
+ {siteLogo && (
106
+ <img
107
+ src={siteLogo.url}
108
+ alt={logoAlt}
109
+ style={{
110
+ width: logoWidth ? `${logoWidth}px` : "auto",
111
+ height: logoHeight ? `${logoHeight}px` : "32px",
112
+ objectFit: "contain",
113
+ }}
114
+ class={`rounded-lg ${darkLogo ? "block dark:hidden" : ""}`}
115
+ />
116
+ )}
117
+ {darkLogo && (
118
+ <img
119
+ src={darkLogo.url}
120
+ alt={logoAlt}
121
+ style={{
122
+ width: logoWidth ? `${logoWidth}px` : "auto",
123
+ height: logoHeight ? `${logoHeight}px` : "32px",
124
+ objectFit: "contain",
125
+ }}
126
+ class={`rounded-lg ${siteLogo ? "hidden dark:block" : ""}`}
127
+ />
128
+ )}
129
+ </div>
111
130
  ) : (
112
131
  <span class="text-2xl font-black tracking-tighter text-[var(--kyro-text-primary)] ">
113
132
  {siteName}
@@ -14,6 +14,7 @@ interface AutoFormHeaderProps {
14
14
  documentStatus: string;
15
15
  hasUnpublishedChanges: boolean;
16
16
  localSaveStatus: "idle" | "saving" | "saved" | "error";
17
+ isDuplicating?: boolean;
17
18
  handleCreateNew: () => void;
18
19
  handleDuplicate: () => void;
19
20
  handleUnpublish: () => void;
@@ -28,6 +29,7 @@ export function AutoFormHeader({
28
29
  documentStatus,
29
30
  hasUnpublishedChanges,
30
31
  localSaveStatus,
32
+ isDuplicating,
31
33
  handleCreateNew,
32
34
  handleDuplicate,
33
35
  handleUnpublish,
@@ -221,6 +223,7 @@ export function AutoFormHeader({
221
223
  {!globalSlug && (
222
224
  <DropdownItem
223
225
  onClick={handleDuplicate}
226
+ disabled={isDuplicating}
224
227
  icon={
225
228
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
226
229
  <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
@@ -228,7 +231,7 @@ export function AutoFormHeader({
228
231
  </svg>
229
232
  }
230
233
  >
231
- Duplicate
234
+ {isDuplicating ? "Duplicating..." : "Duplicate"}
232
235
  </DropdownItem>
233
236
  )}
234
237
  <DropdownItem
@@ -388,7 +391,7 @@ export function AutoFormHeader({
388
391
  </header>
389
392
 
390
393
  {/* DESKTOP HEADER */}
391
- <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">
394
+ <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)] z-50 backdrop-blur-md">
392
395
  <div className="flex flex-col gap-2 min-w-0">
393
396
  <div className="flex items-center gap-3 flex-wrap min-w-0">
394
397
  <a
@@ -17,7 +17,7 @@ import {
17
17
  } from "@dnd-kit/sortable";
18
18
  import { CSS } from "@dnd-kit/utilities";
19
19
 
20
- const SIMPLE_TYPES = new Set(["text", "textarea", "number", "checkbox", "select", "radio", "color", "email", "password", "code", "markdown", "upload"]);
20
+ const SIMPLE_TYPES = new Set(["text", "number", "checkbox", "select", "radio", "color", "email", "password"]);
21
21
 
22
22
  interface ArrayLayoutProps {
23
23
  field: Field;
@@ -102,7 +102,7 @@ function SortableArrayItem({
102
102
  <span className="text-[10px] font-bold text-[var(--kyro-text-muted)] pt-2.5 min-w-[18px] text-center">
103
103
  {index + 1}
104
104
  </span>
105
- <div className="flex-1 flex items-start gap-1.5 min-w-0">
105
+ <div className={`flex-1 min-w-0 ${((field as any).fields || []).length >= 3 ? "flex flex-col gap-1.5" : "flex items-start gap-1.5"}`}>
106
106
  {((field as any).fields || []).map((f: Field) => (
107
107
  <div key={f.name} className="flex-1 min-w-0">
108
108
  {renderField(f, item, onChangeItem)}
@@ -1,6 +1,9 @@
1
+ import React, { useState, useEffect } from "react";
1
2
  import type { SelectField as SelectFieldType } from "@kyro-cms/core/client";
2
3
  import FieldLayout from "./FieldLayout";
3
4
  import { collections } from "../../lib/config";
5
+ import { fetchWithAuth } from "../../lib/api";
6
+ import { apiPath } from "../../lib/paths";
4
7
 
5
8
  interface SelectFieldComponentProps {
6
9
  field: SelectFieldType;
@@ -8,6 +11,10 @@ interface SelectFieldComponentProps {
8
11
  onChange?: (value: string | string[] | undefined) => void;
9
12
  error?: string;
10
13
  disabled?: boolean;
14
+ formData?: Record<string, unknown>;
15
+ siblingData?: Record<string, unknown>;
16
+ collectionSlug?: string;
17
+ globalSlug?: string;
11
18
  }
12
19
 
13
20
  export default function SelectField({
@@ -16,14 +23,71 @@ export default function SelectField({
16
23
  onChange,
17
24
  error,
18
25
  disabled,
26
+ formData,
27
+ siblingData,
28
+ collectionSlug,
29
+ globalSlug,
19
30
  }: SelectFieldComponentProps) {
20
31
  const isReadOnly =
21
32
  typeof field.admin?.readOnly === "function"
22
33
  ? false
23
34
  : Boolean(field.admin?.readOnly);
24
35
 
36
+ const [dynamicOptions, setDynamicOptions] = useState<Array<{ label: string; value: string }> | null>(null);
37
+
38
+ // Debounce stringify for formData to prevent infinite loops or excessive refetches
39
+ const formDataStr = JSON.stringify(formData || {});
40
+ const siblingDataStr = JSON.stringify(siblingData || {});
41
+
42
+ useEffect(() => {
43
+ if (field.options !== "__KYRO_DYNAMIC_OPTIONS__") return;
44
+
45
+ const fetchOptions = async () => {
46
+ try {
47
+ let url = "";
48
+ if (collectionSlug) {
49
+ url = `${apiPath}/${collectionSlug}/dynamic-options/${field.name}`;
50
+ } else if (globalSlug) {
51
+ url = `${apiPath}/globals/${globalSlug}/dynamic-options/${field.name}`;
52
+ } else {
53
+ return;
54
+ }
55
+
56
+ const res = await fetchWithAuth(url, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({
60
+ data: JSON.parse(formDataStr),
61
+ siblingData: JSON.parse(siblingDataStr)
62
+ }),
63
+ });
64
+
65
+ if (res.ok) {
66
+ const json = await res.json();
67
+ if (json.options) {
68
+ setDynamicOptions(json.options);
69
+ }
70
+ }
71
+ } catch (err) {
72
+ console.error("Failed to fetch dynamic options:", err);
73
+ }
74
+ };
75
+
76
+ const timer = setTimeout(fetchOptions, 300);
77
+ return () => clearTimeout(timer);
78
+ }, [formDataStr, siblingDataStr, field.name, field.options, collectionSlug, globalSlug]);
79
+
25
80
  // Resolve dynamic options at runtime if configured
26
- let options = field.options || [];
81
+ let options: Array<{ label: string; value: string }> = dynamicOptions || [];
82
+
83
+ if (field.options !== "__KYRO_DYNAMIC_OPTIONS__") {
84
+ if (typeof field.options === "function") {
85
+ options = field.options({ data: formData || {}, siblingData: siblingData || {} });
86
+ } else if (Array.isArray(field.options)) {
87
+ options = field.options;
88
+ }
89
+ }
90
+
27
91
  if (field.dynamicOptions === "collections") {
28
92
  options = Object.keys(collections)
29
93
  .filter((slug) => slug !== "media")
@@ -52,7 +52,7 @@ export function TabsLayout({
52
52
  )}
53
53
  </div>
54
54
 
55
- {currentTab?.label === "SEO" && (
55
+ {currentTab?.label === "SEO Settings" && (
56
56
  <div className="mt-12 pt-8 border-t border-[var(--kyro-border)]">
57
57
  <h4 className="text-[10px] font-bold text-[var(--kyro-text-secondary)] tracking-[0.2em] mb-6 opacity-50">
58
58
  Live Google Preview