@kyro-cms/admin 0.12.7 → 0.12.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.12.7",
3
+ "version": "0.12.8",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -61,8 +61,8 @@
61
61
  "prepare": "npm run build"
62
62
  },
63
63
  "dependencies": {
64
- "@astrojs/node": "^10.1.0",
65
- "@astrojs/react": "^5.0.4",
64
+ "@astrojs/node": "^11.0.2",
65
+ "@astrojs/react": "^6.0.1",
66
66
  "@codemirror/autocomplete": "^6.20.3",
67
67
  "@codemirror/lang-cpp": "^6.0.3",
68
68
  "@codemirror/lang-css": "^6.3.1",
@@ -81,7 +81,7 @@
81
81
  "@dnd-kit/sortable": "^10.0.0",
82
82
  "@dnd-kit/utilities": "^3.2.2",
83
83
  "@graphiql/react": "^0.37.3",
84
- "@kyro-cms/core": "workspace:*",
84
+ "@kyro-cms/core": "^0.12.7",
85
85
  "@tailwindcss/vite": "^4.0.0",
86
86
  "@tiptap/extension-color": "^3.23.6",
87
87
  "@tiptap/extension-highlight": "^3.23.6",
@@ -100,7 +100,7 @@
100
100
  "@uiw/codemirror-theme-dracula": "^4.25.9",
101
101
  "@uiw/codemirror-theme-github": "^4.25.9",
102
102
  "@uiw/react-codemirror": "^4.25.9",
103
- "astro": "^6.3.1",
103
+ "astro": "^7.1.1",
104
104
  "astro-loading-indicator": "^0.8.0",
105
105
  "dotenv": "^17.4.2",
106
106
  "graphiql": "^5.2.2",
@@ -128,6 +128,7 @@
128
128
  "i18next-parser": "^9.4.0",
129
129
  "tsup": "^8.5.1",
130
130
  "typescript": "^5.7.3",
131
+ "vite": "^8.1.5",
131
132
  "vitest": "^4.1.4"
132
133
  },
133
134
  "peerDependencies": {
@@ -1,5 +1,5 @@
1
1
  import { useEffect } from "react";
2
- import { useAuthStore } from "../lib/stores";
2
+ import { useAuthStore, type AuthUser, type Permissions } from "../lib/stores";
3
3
 
4
4
  /**
5
5
  * AuthBridge
@@ -24,7 +24,7 @@ export function AuthBridge() {
24
24
 
25
25
  function populate(detail: { user: unknown; permissions: unknown }) {
26
26
  if (detail?.user) {
27
- setUser(detail.user, detail.permissions ?? null);
27
+ setUser(detail.user as AuthUser, (detail.permissions ?? null) as Permissions | null);
28
28
  } else {
29
29
  // Unauthenticated – set loading to false so components don't hang
30
30
  setLoading(false);
@@ -10,6 +10,8 @@ import type { DeclarativeCondition } from "../lib/core-types";
10
10
  type View = "edit" | "version" | "api";
11
11
  import { globals, collections } from "../lib/config";
12
12
  import { resolveUrl, apiGet, apiDelete, fetchWithAuth } from "../lib/api";
13
+ import { EmptyState } from "./ui/EmptyState";
14
+ import { navigate } from "astro:transitions/client";
13
15
  import { Shimmer } from "./ui/Shimmer";
14
16
  import { normalizeUploadFields } from "../lib/normalize-upload-fields";
15
17
  import { useAutoFormStore } from "../lib/autoform-store";
@@ -426,11 +428,11 @@ export function AutoForm({
426
428
  onConfirm: async () => {
427
429
  await handleSaveDraft();
428
430
  await new Promise((r) => setTimeout(r, 1000));
429
- window.location.href = `${ADMIN_BASE}/${collectionSlug}/new`;
431
+ navigate(`${ADMIN_BASE}/${collectionSlug}/new`);
430
432
  },
431
433
  });
432
434
  } else {
433
- window.location.href = `${ADMIN_BASE}/${collectionSlug}/new`;
435
+ navigate(`${ADMIN_BASE}/${collectionSlug}/new`);
434
436
  }
435
437
  };
436
438
 
@@ -451,9 +453,9 @@ export function AutoForm({
451
453
  const result = await response.json();
452
454
  onActionSuccess?.("Document duplicated successfully");
453
455
  if (result.data?.id) {
454
- window.location.href = `${ADMIN_BASE}/${collectionSlug}/${result.data.id}`;
456
+ navigate(`${ADMIN_BASE}/${collectionSlug}/${result.data.id}`);
455
457
  } else {
456
- window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
458
+ navigate(`${ADMIN_BASE}/${collectionSlug}`);
457
459
  }
458
460
  } else {
459
461
  const err = await response.json();
@@ -496,7 +498,7 @@ export function AutoForm({
496
498
  autoSaveSkipRef.current = true;
497
499
  try {
498
500
  await apiDelete(`/api/${collectionSlug}/${formData.id}`);
499
- window.location.href = `${ADMIN_BASE}/${collectionSlug}`;
501
+ navigate(`${ADMIN_BASE}/${collectionSlug}`);
500
502
  } catch (err) {
501
503
  toast.error((err as Error).message || "Failed to delete document");
502
504
  } finally {
@@ -543,7 +545,7 @@ export function AutoForm({
543
545
  setLocalSaveStatus("saving");
544
546
 
545
547
  try {
546
- const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
548
+ const data = normalizeUploadFields({ ...formData }, true) as Record<string, unknown>;
547
549
  const isPost = isNewDoc && !globalSlug;
548
550
 
549
551
  const response = isPost
@@ -574,7 +576,7 @@ export function AutoForm({
574
576
  }
575
577
  if (isPost) {
576
578
  setTimeout(() => {
577
- window.location.href = `${ADMIN_BASE}/${collectionSlug}/${result.data.id}`;
579
+ navigate(`${ADMIN_BASE}/${collectionSlug}/${result.data.id}`);
578
580
  }, 800);
579
581
  }
580
582
  } else {
@@ -606,7 +608,7 @@ export function AutoForm({
606
608
 
607
609
  if (isNewDoc && !globalSlug) {
608
610
  // Create then immediately publish
609
- const data = normalizeUploadFields({ ...formData }) as Record<string, unknown>;
611
+ const data = normalizeUploadFields({ ...formData }, true) as Record<string, unknown>;
610
612
  const response = await fetchWithAuth(`/api/${collectionSlug}`, {
611
613
  method: "POST",
612
614
  headers: { "Content-Type": "application/json" },
@@ -627,7 +629,7 @@ export function AutoForm({
627
629
  }
628
630
 
629
631
  // Save and publish (X-Draft: false writes to main doc + versions table)
630
- const data = normalizeUploadFields(dataToPublish) as Record<string, unknown>;
632
+ const data = normalizeUploadFields(dataToPublish, true) as Record<string, unknown>;
631
633
  const response = await saveDocument(data, false);
632
634
 
633
635
  if (response?.ok) {
@@ -643,7 +645,7 @@ export function AutoForm({
643
645
 
644
646
  if (isNewDoc && !globalSlug && dataToPublish.id) {
645
647
  setTimeout(() => {
646
- window.location.href = `${ADMIN_BASE}/${collectionSlug}/${dataToPublish.id}`;
648
+ navigate(`${ADMIN_BASE}/${collectionSlug}/${dataToPublish.id}`);
647
649
  }, 800);
648
650
  }
649
651
  } else {
@@ -653,7 +655,7 @@ export function AutoForm({
653
655
  const error = await response?.json().catch(() => ({}));
654
656
  toast.warning("Document saved as draft. Publishing failed: " + (error?.error || "Unknown error"));
655
657
  setTimeout(() => {
656
- window.location.href = `${ADMIN_BASE}/${collectionSlug}/${dataToPublish.id}`;
658
+ navigate(`${ADMIN_BASE}/${collectionSlug}/${dataToPublish.id}`);
657
659
  }, 1200);
658
660
  return;
659
661
  }
@@ -679,7 +681,7 @@ export function AutoForm({
679
681
 
680
682
  try {
681
683
  const data = {
682
- ...normalizeUploadFields({ ...formData }) as Record<string, unknown>,
684
+ ...normalizeUploadFields({ ...formData }, true) as Record<string, unknown>,
683
685
  _schedulePublishAt: scheduledFor,
684
686
  };
685
687
 
@@ -59,7 +59,7 @@ export function BrandingHub() {
59
59
  "--kyro-primary",
60
60
  primaryColor,
61
61
  );
62
- setTimeout(() => window.location.reload(), 800);
62
+ setTimeout(() => window.dispatchEvent(new Event('kyro:soft-reload')), 800);
63
63
  } catch (e) {
64
64
  toast.error("Failed to save branding");
65
65
  console.error(e);
@@ -14,6 +14,8 @@ import { useAutoFormStore } from "../lib/autoform-store";
14
14
  import { PageHeader } from "./ui/PageHeader";
15
15
  import { Badge } from "./ui/Badge";
16
16
  import { SplitButton } from "./ui/SplitButton";
17
+ import { Lock, FileText, ChevronLeft, Save } from "lucide-react";
18
+ import { navigate } from "astro:transitions/client";
17
19
  import { useTranslation } from "react-i18next";
18
20
  import "../lib/i18n";
19
21
  import { adminPath } from "../lib/paths";
@@ -231,9 +233,9 @@ export function DetailView({
231
233
  const response = await apiPost(`/api/${slug}/${documentId}/duplicate`, undefined, { autoToast: false }) as { data?: { id?: string } };
232
234
  toast.success(t("toast.duplicated", { defaultValue: "Document duplicated" }));
233
235
  if (response?.data?.id) {
234
- window.location.href = `${adminPath}/${slug}/${response.data.id}`;
236
+ navigate(`${adminPath}/${slug}/${response.data.id}`);
235
237
  } else {
236
- window.location.href = `${adminPath}/${slug}`;
238
+ navigate(`${adminPath}/${slug}`);
237
239
  }
238
240
  } catch (e) {
239
241
  toast.error((e as Error).message || t("toast.duplicateError", { defaultValue: "Failed to duplicate document" }));
@@ -290,7 +292,7 @@ export function DetailView({
290
292
  try {
291
293
  setIsDeleting(true);
292
294
  await apiDelete(`/api/${slug}/${documentId}`);
293
- window.location.href = `${adminPath}/${slug}`;
295
+ navigate(`${adminPath}/${slug}`);
294
296
  } catch (e) {
295
297
  setIsDeleting(false);
296
298
  }
@@ -1,6 +1,7 @@
1
1
  import { Search, Filter, Columns3, X, Trash2, Archive, ChevronUp, Edit2 } from "./ui/icons";
2
2
  import { useState, useEffect, useMemo, useCallback, useRef } from "react";
3
3
  import { Spinner } from "./ui/Spinner";
4
+ import { navigate } from "astro:transitions/client";
4
5
  import { Shimmer } from "./ui/Shimmer";
5
6
  import { Plus } from "./ui/icons";
6
7
  import { apiGet, apiDelete, withCacheBust } from "../lib/api";
@@ -78,7 +79,7 @@ export function ListView({
78
79
  import("astro:transitions/client").then(({ navigate }) => {
79
80
  navigate(href);
80
81
  }).catch(() => {
81
- window.location.href = href;
82
+ navigate(href);
82
83
  });
83
84
  }
84
85
  };
@@ -92,7 +93,7 @@ export function ListView({
92
93
  import("astro:transitions/client").then(({ navigate }) => {
93
94
  navigate(href);
94
95
  }).catch(() => {
95
- window.location.href = href;
96
+ navigate(href);
96
97
  });
97
98
  }
98
99
  };
@@ -104,7 +105,16 @@ export function ListView({
104
105
  const [limit, setLimit] = useState(10);
105
106
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
106
107
  const [search, setSearch] = useState("");
108
+ const [debouncedSearch, setDebouncedSearch] = useState("");
107
109
  const [filters, setFilters] = useState<FilterConfig[]>([]);
110
+
111
+ useEffect(() => {
112
+ const handler = setTimeout(() => {
113
+ setDebouncedSearch(search);
114
+ setPage(1); // Reset to page 1 on new search
115
+ }, 300);
116
+ return () => clearTimeout(handler);
117
+ }, [search]);
108
118
  const { confirm, alert } = useUIStore();
109
119
 
110
120
  const addFilter = () => {
@@ -262,7 +272,7 @@ export function ListView({
262
272
  depth: "1",
263
273
  });
264
274
 
265
- if (search) params.append("search", search);
275
+ if (debouncedSearch) params.append("search", debouncedSearch);
266
276
  if (sort) params.append("sort", sort.field);
267
277
  if (sort) params.append("order", sort.direction);
268
278
  if (filters.length > 0) {
@@ -280,7 +290,7 @@ export function ListView({
280
290
  } finally {
281
291
  setLoading(false);
282
292
  }
283
- }, [collectionSlug, page, limit, search, sort, filters]);
293
+ }, [collectionSlug, page, limit, debouncedSearch, sort, filters]);
284
294
 
285
295
  // Initial fetch only if not provided with initialDocs or if empty
286
296
  useEffect(() => {
@@ -297,7 +307,7 @@ export function ListView({
297
307
  return;
298
308
  }
299
309
  fetchDocs();
300
- }, [page, limit, search, sort, filters]);
310
+ }, [page, limit, debouncedSearch, sort, filters]);
301
311
 
302
312
  const handleSelectAll = () => {
303
313
  if (selectedIds.size === docs.length) {
@@ -1256,7 +1256,7 @@ export function MediaGallery({
1256
1256
  }).then(() => {
1257
1257
  setShowStorageConfigModal(false);
1258
1258
  setStorageConfigured(true);
1259
- window.location.reload();
1259
+ window.dispatchEvent(new Event('kyro:soft-reload'));
1260
1260
  }).catch(() => {
1261
1261
  toast.error("Failed to configure storage");
1262
1262
  });
@@ -16,6 +16,7 @@ import { Modal, ModalContent, ModalActions } from "./ui/Modal";
16
16
  import { PageHeader } from "./ui/PageHeader";
17
17
  import { Badge } from "./ui/Badge";
18
18
  import { useTranslation } from "react-i18next";
19
+ import { navigate } from "astro:transitions/client";
19
20
 
20
21
 
21
22
  interface Plugin {
@@ -97,7 +98,7 @@ export function PluginsManager() {
97
98
  actions={[
98
99
  {
99
100
  label: "Marketplace",
100
- onClick: () => (window.location.href = "/admin/marketplace"),
101
+ onClick: () => (navigate("/admin/marketplace")),
101
102
  icon: Plus,
102
103
  },
103
104
  ]}
@@ -4,6 +4,7 @@ import { User, Shield, Key, Webhook, Clock, FileText, ExternalLink, HelpCircle,
4
4
  import { useAuthStore } from "../lib/stores";
5
5
  import { apiGet } from "../lib/api";
6
6
  import { useTranslation } from "react-i18next";
7
+ import { navigate } from 'astro:transitions/client';
7
8
 
8
9
  interface UserMenuProps {
9
10
  adminPath: string;
@@ -64,9 +65,9 @@ export function UserMenu({ adminPath }: UserMenuProps) {
64
65
  onClick={() => {
65
66
  const id = currentUser?.id;
66
67
  if (id) {
67
- window.location.href = `${adminPath}/users/${id}`;
68
+ navigate(`${adminPath}/users/${id}`);
68
69
  } else {
69
- window.location.href = `${adminPath}/users`;
70
+ navigate(`${adminPath}/users`);
70
71
  }
71
72
  }}
72
73
  >
@@ -75,7 +76,7 @@ export function UserMenu({ adminPath }: UserMenuProps) {
75
76
 
76
77
  <DropdownItem
77
78
  icon={<Shield className="w-4 h-4" />}
78
- onClick={() => window.location.href = `${adminPath}/roles`}
79
+ onClick={() => navigate(`${adminPath}/roles`)}
79
80
  >
80
81
  {t("userMenu.permissions", { defaultValue: "Permissions" })}
81
82
  </DropdownItem>
@@ -90,33 +91,33 @@ export function UserMenu({ adminPath }: UserMenuProps) {
90
91
 
91
92
  <DropdownItem
92
93
  icon={<Key className="w-4 h-4" />}
93
- onClick={() => (window.location.href = `${adminPath}/keys`)}
94
+ onClick={() => (navigate(`${adminPath}/keys`))}
94
95
  >
95
96
  {t("userMenu.apiKeys", { defaultValue: "API Keys" })}
96
97
  </DropdownItem>
97
98
 
98
99
  <DropdownItem
99
100
  icon={<Webhook className="w-4 h-4" />}
100
- onClick={() => (window.location.href = `${adminPath}/webhooks`)}
101
+ onClick={() => (navigate(`${adminPath}/webhooks`))}
101
102
  >
102
103
  {t("userMenu.webHooks", { defaultValue: "Web Hooks" })}
103
104
  </DropdownItem>
104
105
  <DropdownItem
105
106
  icon={<Clock className="w-4 h-4" />}
106
- onClick={() => (window.location.href = `${adminPath}/sessions`)}
107
+ onClick={() => (navigate(`${adminPath}/sessions`))}
107
108
  >
108
109
  {t("userMenu.sessions", { defaultValue: "Sessions" })}
109
110
  </DropdownItem>
110
111
  <DropdownItem
111
112
  icon={<FileText className="w-4 h-4" />}
112
- onClick={() => (window.location.href = `${adminPath}/audit`)}
113
+ onClick={() => (navigate(`${adminPath}/audit`))}
113
114
  >
114
115
  {t("userMenu.auditLogs", { defaultValue: "Audit Logs" })}
115
116
  </DropdownItem>
116
117
 
117
118
  <DropdownItem
118
119
  icon={<Terminal className="w-4 h-4" />}
119
- onClick={() => (window.location.href = `${adminPath}/rest-playground`)}
120
+ onClick={() => (navigate(`${adminPath}/rest-playground`))}
120
121
  >
121
122
  {t("userMenu.apiExplorer", { defaultValue: "API Explorer" })}
122
123
  </DropdownItem>
@@ -124,7 +125,7 @@ export function UserMenu({ adminPath }: UserMenuProps) {
124
125
  {(apiAccess === null || apiAccess?.graphqlEnabled) && (
125
126
  <DropdownItem
126
127
  icon={<Zap className="w-4 h-4" />}
127
- onClick={() => (window.location.href = `${adminPath}/graphql`)}
128
+ onClick={() => (navigate(`${adminPath}/graphql`))}
128
129
  >
129
130
  {t("userMenu.graphqlPlayground", { defaultValue: "GraphQL Playground" })}
130
131
  </DropdownItem>
@@ -44,7 +44,7 @@ export function AutoFormApiView({
44
44
  </div>
45
45
  </div>
46
46
 
47
- {formData.id && (
47
+ {Boolean(formData.id) && (
48
48
  <div>
49
49
  <label className="text-[10px] font-bold tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
50
50
  Document ID
@@ -179,7 +179,7 @@ export function AutoFormHeader({
179
179
  <span className="opacity-30">|</span>
180
180
  <button
181
181
  type="button"
182
- onClick={() => window.location.reload()}
182
+ onClick={() => window.dispatchEvent(new Event('kyro:soft-reload'))}
183
183
  className="text-[var(--kyro-danger)] hover:underline"
184
184
  >
185
185
  Reload server version
@@ -45,7 +45,7 @@ export class ErrorBoundary extends Component<Props, State> {
45
45
  };
46
46
 
47
47
  handleReload = () => {
48
- window.location.reload();
48
+ window.dispatchEvent(new Event('kyro:soft-reload'));
49
49
  };
50
50
 
51
51
  render() {
@@ -43,7 +43,7 @@ export default function IconField({
43
43
  type="text"
44
44
  value={normalizedValue}
45
45
  onChange={(e) => onChange?.(e.target.value)}
46
- placeholder={field.admin?.placeholder || "e.g., activity"}
46
+ placeholder={(field.admin?.placeholder as string) || "e.g., activity"}
47
47
  disabled={disabled || isReadOnly}
48
48
  required={field.required}
49
49
  className={`kyro-form-input ${disabled || isReadOnly ? "opacity-70 bg-[var(--kyro-bg-secondary)] cursor-not-allowed" : ""}`}
@@ -1,8 +1,11 @@
1
1
  import type { ButtonHTMLAttributes, ReactNode } from 'react';
2
2
 
3
+ export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
4
+ export type ButtonSize = 'sm' | 'md' | 'lg';
5
+
3
6
  interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
4
- variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
5
- size?: 'sm' | 'md' | 'lg';
7
+ variant?: ButtonVariant;
8
+ size?: ButtonSize;
6
9
  loading?: boolean;
7
10
  children: ReactNode;
8
11
  }
@@ -1,9 +1,9 @@
1
- import React, { useState, useEffect } from "react";
1
+ import { useState, useEffect } from "react";
2
2
  import { apiGet } from "../../lib/api";
3
3
  import { useUIStore, toast } from "../../lib/stores";
4
- import { X } from "../ui/icons";
5
4
  import { UploadField } from "../fields/UploadField";
6
5
  import { useTranslation } from "react-i18next";
6
+ import { navigate } from 'astro:transitions/client';
7
7
 
8
8
  interface User {
9
9
  id: string;
@@ -43,8 +43,8 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
43
43
  const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
44
44
  const [avatarMedia, setAvatarMedia] = useState<any>(user.avatar ? { id: user.avatar } : null);
45
45
  const [saving, setSaving] = useState(false);
46
- const { confirm, alert } = useUIStore();
47
- const [deleting, setDeleting] = useState(false);
46
+ const { confirm } = useUIStore();
47
+ const [, setDeleting] = useState(false);
48
48
  const [locking, setLocking] = useState(false);
49
49
  const [isLocked, setIsLocked] = useState(user.locked || false);
50
50
 
@@ -90,7 +90,7 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
90
90
  }
91
91
 
92
92
  if (Object.keys(body).length === 0) {
93
- window.location.href = adminPath + "/users";
93
+ navigate(adminPath + "/users");
94
94
  return;
95
95
  }
96
96
 
@@ -105,7 +105,7 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
105
105
 
106
106
  if (res.ok) {
107
107
  toast.success("User updated");
108
- window.location.href = adminPath + "/users";
108
+ navigate(adminPath + "/users");
109
109
  } else {
110
110
  toast.error(data.error || "Failed to save user");
111
111
  }
@@ -162,7 +162,7 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
162
162
  });
163
163
  if (res.ok) {
164
164
  toast.success("User deleted");
165
- window.location.href = adminPath + "/users";
165
+ navigate(adminPath + "/users");
166
166
  } else {
167
167
  toast.error("Failed to delete user");
168
168
  }
@@ -1,5 +1,6 @@
1
1
  import React, { useState } from "react";
2
2
  import { useTranslation } from "react-i18next";
3
+ import { navigate } from 'astro:transitions/client';
3
4
 
4
5
  interface UserFormProps {
5
6
  mode: "create" | "edit";
@@ -38,7 +39,7 @@ export function UserForm({ mode, apiPath, adminPath, user }: UserFormProps) {
38
39
  type: "success" | "error";
39
40
  } | null>(null);
40
41
 
41
- const handleSubmit = async (e: React.FormEvent) => {
42
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
42
43
  e.preventDefault();
43
44
  setLoading(true);
44
45
  setMessage(null);
@@ -71,7 +72,7 @@ export function UserForm({ mode, apiPath, adminPath, user }: UserFormProps) {
71
72
  type: "success",
72
73
  });
73
74
  setTimeout(() => {
74
- window.location.href = `${adminPath}/users`;
75
+ navigate(`${adminPath}/users`);
75
76
  }, 1000);
76
77
  } else {
77
78
  setMessage({
@@ -1,6 +1,7 @@
1
1
  import { Plus, Lock, CheckCircle2, Edit2, Trash2, XCircle, X } from "../ui/icons";
2
- import React, { useState } from "react";
2
+ import { useState } from "react";
3
3
  import { useUIStore } from "../../lib/stores";
4
+ import { navigate } from 'astro:transitions/client';
4
5
 
5
6
  interface User {
6
7
  id: string;
@@ -39,8 +40,8 @@ export function UsersList({
39
40
  }: UsersListProps) {
40
41
  const [users, setUsers] = useState<User[]>(initialUsers);
41
42
  const [totalUsers, setTotalUsers] = useState(initialTotal);
42
- const { confirm, alert } = useUIStore();
43
- const [deleting, setDeleting] = useState(false);
43
+ const { confirm } = useUIStore();
44
+ const [, setDeleting] = useState(false);
44
45
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
45
46
 
46
47
  const handleDeleteClick = (user: User) => {
@@ -168,7 +169,7 @@ export function UsersList({
168
169
  key={user.id}
169
170
  className="group hover:bg-gray-50/50 transition-colors cursor-pointer"
170
171
  onClick={() =>
171
- (window.location.href = `${adminPath}/users/${user.id}`)
172
+ navigate(`${adminPath}/users/${user.id}`)
172
173
  }
173
174
  >
174
175
  <td className="px-8 py-5">
package/src/env.d.ts CHANGED
@@ -2,6 +2,10 @@ declare const __KYRO_API_PATH__: string;
2
2
  declare const __KYRO_ADMIN_PATH__: string;
3
3
  declare const __KYRO_ADMIN_AUTH_DISABLED__: boolean;
4
4
 
5
+ declare module 'astro:transitions/client' {
6
+ export function navigate(href: string, options?: any): void;
7
+ }
8
+
5
9
  declare namespace App {
6
10
  interface Locals {
7
11
  user?: {
@@ -14,7 +14,6 @@ type QueueTask = (fn: QueuedFunction, options?: QueueTaskOptions) => void;
14
14
  * Only the last task in the queue is ever processed; all prior pending tasks are discarded.
15
15
  * This prevents race conditions where multiple autosave fetches could run in parallel.
16
16
  *
17
- * Inspired by Payload's useQueues hook.
18
17
  *
19
18
  * @returns {queueTask} A function used to queue a task for execution.
20
19
  */
@@ -40,7 +39,9 @@ export function useQueue(): { queueTask: QueueTask } {
40
39
  isProcessing.current = true;
41
40
 
42
41
  try {
43
- await latestTask();
42
+ if (latestTask) {
43
+ await latestTask();
44
+ }
44
45
  } catch (err) {
45
46
  console.error("Error in queued function:", err);
46
47
  } finally {
package/src/kyro-cms.d.ts CHANGED
@@ -62,6 +62,8 @@ admin?: {
62
62
  export interface JSONField extends FieldConfig { type: 'json' }
63
63
  export interface ImageField extends FieldConfig { type: 'image' }
64
64
  export interface UploadField extends FieldConfig { type: 'upload' }
65
+ export interface IconField extends FieldConfig { type: 'icon' }
66
+ export interface SecretField extends FieldConfig { type: 'secret' }
65
67
  export interface RelationshipField extends FieldConfig { type: 'relationship'; relationTo: string }
66
68
  export interface BlocksField extends FieldConfig { type: 'blocks'; blocks: Block[] }
67
69
  export interface ArrayField extends FieldConfig { type: 'array'; fields: FieldConfig[] }
@@ -181,6 +183,9 @@ admin?: {
181
183
  newValue?: unknown;
182
184
  [key: string]: unknown;
183
185
  }
186
+
187
+ export const richTextStyles: string;
188
+ export function renderRichText(content: unknown, options?: unknown): string;
184
189
  }
185
190
 
186
191
  declare module '@kyro-cms/core/client' {
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from "zustand/middleware";
3
3
  import type { StateStorage } from "zustand/middleware";
4
4
  import { createStorage } from "unstorage";
5
5
  import indexedbDriver from "unstorage/drivers/indexedb";
6
- import type { Version } from "@kyro-cms/core/client";
6
+ import type { Version, VersionDiff } from "@kyro-cms/core/client";
7
7
  import { deepEqual, isEmpty } from "./deep-equal";
8
8
  import { normalizeUploadFields } from "./normalize-upload-fields";
9
9
 
@@ -247,8 +247,9 @@ export const useAutoFormStore = create<AutoFormStore>()(
247
247
  if (current[keys[i]] === undefined) {
248
248
  current[keys[i]] = {};
249
249
  }
250
- current[keys[i]] = { ...current[keys[i]] };
251
- current = current[keys[i]];
250
+ const nextVal = current[keys[i]] as Record<string, unknown>;
251
+ current[keys[i]] = { ...nextVal };
252
+ current = current[keys[i]] as Record<string, unknown>;
252
253
  }
253
254
 
254
255
  current[keys[keys.length - 1]] = value;
@@ -7,13 +7,13 @@
7
7
  * Without normalization, the server stores the full object as a JSON string,
8
8
  * which causes 404s on the next load when trying to fetch the media.
9
9
  */
10
- export function normalizeUploadFields(value: unknown): unknown {
10
+ export function normalizeUploadFields(value: unknown, isRoot = false): unknown {
11
11
  if (value === null || value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
12
12
  return value;
13
13
  }
14
14
 
15
15
  if (Array.isArray(value)) {
16
- return value.map(normalizeUploadFields);
16
+ return value.map(v => normalizeUploadFields(v, false));
17
17
  }
18
18
 
19
19
  if (typeof value === "object") {
@@ -25,14 +25,27 @@ export function normalizeUploadFields(value: unknown): unknown {
25
25
  const hasId = "id" in obj && (typeof obj.id === "string" || obj.id === null);
26
26
  const hasMediaField = "url" in obj && ("filename" in obj || "mimeType" in obj);
27
27
 
28
- if (hasId && hasMediaField && keys.length <= 25) {
28
+ if (!isRoot && hasId && hasMediaField && keys.length <= 25) {
29
+ return obj.id;
30
+ }
31
+
32
+ // Heuristic for polymorphic relationships: { relationTo: '...', value: { id: '...' } }
33
+ if (!isRoot && "relationTo" in obj && "value" in obj && typeof obj.value === "object" && obj.value !== null) {
34
+ const valObj = obj.value as Record<string, unknown>;
35
+ if ("id" in valObj && (typeof valObj.id === "string" || typeof valObj.id === "number")) {
36
+ return { relationTo: obj.relationTo, value: valObj.id };
37
+ }
38
+ }
39
+
40
+ // Heuristic for regular populated relationships: must have id, createdAt, updatedAt
41
+ if (!isRoot && hasId && "createdAt" in obj && "updatedAt" in obj) {
29
42
  return obj.id;
30
43
  }
31
44
 
32
45
  // Recursively normalize nested objects (tabs, groups, blocks, arrays)
33
46
  const result: Record<string, unknown> = {};
34
47
  for (const key of keys) {
35
- result[key] = normalizeUploadFields(obj[key]);
48
+ result[key] = normalizeUploadFields(obj[key], false);
36
49
  }
37
50
  return result;
38
51
  }