@kyro-cms/admin 0.9.0 → 0.9.2

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.
Files changed (114) hide show
  1. package/dist/index.cjs +11715 -11292
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +67 -65
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +564 -0
  6. package/dist/index.d.ts +11 -10
  7. package/dist/index.js +11326 -10912
  8. package/dist/index.js.map +1 -1
  9. package/package.json +16 -12
  10. package/src/components/ActionBar.tsx +25 -161
  11. package/src/components/Admin.tsx +2 -4
  12. package/src/components/ApiKeysManager.tsx +5 -5
  13. package/src/components/AuditLogsPage.tsx +2 -13
  14. package/src/components/AutoForm.tsx +572 -461
  15. package/src/components/BrandingHub.tsx +7 -4
  16. package/src/components/CreateView.tsx +2 -0
  17. package/src/components/DetailView.tsx +52 -65
  18. package/src/components/DeveloperCenter.tsx +8 -6
  19. package/src/components/FieldRenderer.tsx +94 -19
  20. package/src/components/ListView.tsx +57 -216
  21. package/src/components/MediaGallery.tsx +334 -367
  22. package/src/components/PluginsManager.tsx +197 -70
  23. package/src/components/RestPlayground.tsx +59 -52
  24. package/src/components/SessionsManager.tsx +1 -1
  25. package/src/components/SettingsPage.tsx +22 -0
  26. package/src/components/Sidebar.astro +13 -41
  27. package/src/components/UserManagement.tsx +153 -15
  28. package/src/components/UserMenu.tsx +30 -4
  29. package/src/components/VersionHistoryPanel.tsx +112 -119
  30. package/src/components/WebhookManager.tsx +6 -4
  31. package/src/components/blocks/ArrayBlock.tsx +6 -23
  32. package/src/components/blocks/BlockEditModal.tsx +82 -309
  33. package/src/components/blocks/CardBlock.tsx +35 -0
  34. package/src/components/blocks/ChildBlocksTree.tsx +57 -31
  35. package/src/components/blocks/GenericBlock.tsx +44 -0
  36. package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
  37. package/src/components/blocks/HeroBlock.tsx +5 -14
  38. package/src/components/blocks/RichTextBlock.tsx +5 -5
  39. package/src/components/blocks/index.ts +5 -3
  40. package/src/components/fields/AccordionField.tsx +2 -2
  41. package/src/components/fields/ArrayField.tsx +1 -1
  42. package/src/components/fields/ArrayLayout.tsx +120 -29
  43. package/src/components/fields/BlocksField.tsx +433 -55
  44. package/src/components/fields/CardField.tsx +73 -0
  45. package/src/components/fields/CheckboxField.tsx +7 -3
  46. package/src/components/fields/DateField.tsx +4 -1
  47. package/src/components/fields/GroupLayout.tsx +2 -2
  48. package/src/components/fields/HeadingSubheadingField.tsx +43 -0
  49. package/src/components/fields/ListField.tsx +2 -2
  50. package/src/components/fields/NumberField.tsx +4 -1
  51. package/src/components/fields/RelationshipBlockField.tsx +2 -3
  52. package/src/components/fields/RelationshipField.tsx +155 -90
  53. package/src/components/fields/RichTextField.tsx +781 -0
  54. package/src/components/fields/SecretField.tsx +102 -0
  55. package/src/components/fields/SelectField.tsx +19 -6
  56. package/src/components/fields/TabsLayout.tsx +19 -9
  57. package/src/components/fields/TextField.tsx +4 -1
  58. package/src/components/fields/UploadField.tsx +122 -56
  59. package/src/components/fields/extensions/blockComponents.tsx +103 -174
  60. package/src/components/fields/extensions/blocksStore.ts +8 -1
  61. package/src/components/fields/index.ts +4 -2
  62. package/src/components/fix_imports.cjs +23 -0
  63. package/src/components/fix_imports2.cjs +19 -0
  64. package/src/components/replace_svgs.cjs +63 -0
  65. package/src/components/ui/Dropdown.tsx +7 -2
  66. package/src/components/ui/Modal.tsx +24 -27
  67. package/src/components/ui/PageHeader.tsx +5 -5
  68. package/src/components/ui/PromptModal.tsx +2 -10
  69. package/src/components/ui/SlidePanel.tsx +10 -13
  70. package/src/components/ui/SplitButton.tsx +107 -0
  71. package/src/components/ui/Toaster.tsx +0 -1
  72. package/src/components/ui/icons.tsx +110 -109
  73. package/src/components/users/UserDetail.tsx +79 -16
  74. package/src/components/users/UsersList.tsx +8 -85
  75. package/src/hooks/useAutoFormState.ts +187 -196
  76. package/src/hooks/useQueue.ts +60 -0
  77. package/src/integration.ts +148 -46
  78. package/src/kyro-cms.d.ts +7 -2
  79. package/src/layouts/AdminLayout.astro +22 -2
  80. package/src/layouts/AuthLayout.astro +67 -7
  81. package/src/lib/autoform-store.ts +90 -53
  82. package/src/lib/change-source.ts +9 -0
  83. package/src/lib/config.ts +104 -8
  84. package/src/lib/globals.ts +48 -11
  85. package/src/lib/normalize-upload-fields.ts +41 -0
  86. package/src/lib/paths.ts +2 -2
  87. package/src/lib/resolve-field-value.ts +110 -0
  88. package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
  89. package/src/lib/shim/use-sync-external-store.js +1 -0
  90. package/src/lib/stores/index.ts +1 -0
  91. package/src/lib/useResourceManager.ts +4 -4
  92. package/src/lib/vite-shim-plugin.ts +100 -0
  93. package/src/pages/[collection]/[id].astro +1 -1
  94. package/src/pages/auth/register.astro +5 -2
  95. package/src/pages/preview/[collection]/[id].astro +4 -4
  96. package/src/pages/settings/[slug].astro +2 -2
  97. package/src/styles/main.css +60 -54
  98. package/README.md +0 -46
  99. package/dist/EditorClient-Q23UXR37.cjs +0 -468
  100. package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
  101. package/dist/EditorClient-T5PASFNR.js +0 -466
  102. package/dist/EditorClient-T5PASFNR.js.map +0 -1
  103. package/dist/chunk-3BGDYKTD.cjs +0 -348
  104. package/dist/chunk-3BGDYKTD.cjs.map +0 -1
  105. package/dist/chunk-EEFXLQVT.js +0 -3
  106. package/dist/chunk-EEFXLQVT.js.map +0 -1
  107. package/src/components/blocks/ButtonBlock.tsx +0 -64
  108. package/src/components/blocks/ColumnsBlock.tsx +0 -55
  109. package/src/components/blocks/DividerBlock.tsx +0 -43
  110. package/src/components/blocks/LinkBlock.tsx +0 -65
  111. package/src/components/blocks/VStackBlock.tsx +0 -29
  112. package/src/components/fields/EditorClient.tsx +0 -535
  113. package/src/components/fields/PortableTextField.tsx +0 -155
  114. package/src/components/fields/PortableTextRenderer.tsx +0 -68
@@ -0,0 +1,73 @@
1
+ import React from "react";
2
+
3
+ interface CardFieldProps {
4
+ title?: string;
5
+ description?: string;
6
+ icon?: string;
7
+ link?: string;
8
+ linkText?: string;
9
+ onChange: (field: string, value: string) => void;
10
+ compact?: boolean;
11
+ }
12
+
13
+ export const CardField: React.FC<CardFieldProps> = ({
14
+ title = "",
15
+ description = "",
16
+ icon = "",
17
+ link = "",
18
+ linkText = "",
19
+ onChange,
20
+ compact = false,
21
+ }) => {
22
+ const inputClass = compact
23
+ ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
24
+ : "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
25
+
26
+ const textareaClass = compact
27
+ ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[50px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
28
+ : "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[80px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
29
+
30
+ return (
31
+ <div className={compact ? "space-y-2" : "space-y-3"}>
32
+ <input
33
+ type="text"
34
+ value={title}
35
+ onChange={(e) => onChange("title", e.target.value)}
36
+ className={`${inputClass} font-bold text-base`}
37
+ placeholder="Card title..."
38
+ />
39
+ <textarea
40
+ value={description}
41
+ onChange={(e) => onChange("description", e.target.value)}
42
+ className={textareaClass}
43
+ placeholder="Card description..."
44
+ />
45
+ <input
46
+ type="text"
47
+ value={icon}
48
+ onChange={(e) => onChange("icon", e.target.value)}
49
+ className={inputClass}
50
+ placeholder="Icon (emoji or name)..."
51
+ />
52
+ <div className="flex items-center gap-2">
53
+ <input
54
+ type="text"
55
+ value={linkText}
56
+ onChange={(e) => onChange("linkText", e.target.value)}
57
+ className="flex-1 px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
58
+ placeholder="Link text..."
59
+ />
60
+ <span className="text-[var(--kyro-text-muted)] text-xs">→</span>
61
+ <input
62
+ type="url"
63
+ value={link}
64
+ onChange={(e) => onChange("link", e.target.value)}
65
+ className="flex-1 px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent font-mono text-xs"
66
+ placeholder="https://..."
67
+ />
68
+ </div>
69
+ </div>
70
+ );
71
+ };
72
+
73
+ export default CardField;
@@ -11,12 +11,16 @@ interface CheckboxFieldComponentProps {
11
11
 
12
12
  export default function CheckboxField({
13
13
  field,
14
- value = false,
14
+ value,
15
15
  onChange,
16
16
  error,
17
17
  disabled,
18
18
  }: CheckboxFieldComponentProps) {
19
- const isReadOnly = field.admin?.readOnly;
19
+ const isReadOnly =
20
+ typeof field.admin?.readOnly === "function"
21
+ ? false
22
+ : Boolean(field.admin?.readOnly);
23
+ const checked = value ?? false;
20
24
 
21
25
  return (
22
26
  <FieldLayout
@@ -27,7 +31,7 @@ export default function CheckboxField({
27
31
  <label className="flex items-center gap-2.5 cursor-pointer group py-0.5">
28
32
  <input
29
33
  type="checkbox"
30
- checked={value}
34
+ checked={checked}
31
35
  onChange={(e) => onChange?.(e.target.checked)}
32
36
  disabled={disabled || isReadOnly}
33
37
  className={`w-4 h-4 rounded border-[var(--kyro-border)] text-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)] transition-all ${
@@ -16,7 +16,10 @@ export default function DateField({
16
16
  error,
17
17
  disabled,
18
18
  }: DateFieldComponentProps) {
19
- const isReadOnly = field.admin?.readOnly;
19
+ const isReadOnly =
20
+ typeof field.admin?.readOnly === "function"
21
+ ? false
22
+ : Boolean(field.admin?.readOnly);
20
23
 
21
24
  return (
22
25
  <FieldLayout
@@ -22,10 +22,10 @@ export function GroupLayout({
22
22
 
23
23
  return (
24
24
  <div className="kyro-form-group border border-[var(--kyro-border)] rounded-[var(--kyro-radius-lg)] p-6 bg-[var(--kyro-surface-accent)]/30">
25
- <h3 className="text-sm font-bold tracking-widest text-[var(--kyro-text-primary)] mb-6 border-b border-[var(--kyro-border)] pb-2 inline-block">
25
+ <h3 className="text-sm font-bold tracking-widest text-[var(--kyro-text-primary)] mb-6 border-b border-[var(--kyro-border)] pb-2 inline-block">
26
26
  {field.label || field.name}
27
27
  </h3>
28
- <div className="space-y-6">
28
+ <div className={field.admin?.inline ? "flex items-start gap-4" : "space-y-6"}>
29
29
  {(field as Field & { fields?: Field[] }).fields.map((f: Field) =>
30
30
  renderField(f, groupData, onChange),
31
31
  )}
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+
3
+ interface HeadingSubheadingFieldProps {
4
+ heading?: string;
5
+ subheading?: string;
6
+ onChange: (field: string, value: string) => void;
7
+ compact?: boolean;
8
+ }
9
+
10
+ export const HeadingSubheadingField: React.FC<HeadingSubheadingFieldProps> = ({
11
+ heading = "",
12
+ subheading = "",
13
+ onChange,
14
+ compact = false,
15
+ }) => {
16
+ const inputClass = compact
17
+ ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
18
+ : "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
19
+
20
+ const textareaClass = compact
21
+ ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[50px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
22
+ : "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[80px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
23
+
24
+ return (
25
+ <div className={compact ? "space-y-2" : "space-y-3"}>
26
+ <input
27
+ type="text"
28
+ value={heading}
29
+ onChange={(e) => onChange("heading", e.target.value)}
30
+ className={`${inputClass} font-bold text-base`}
31
+ placeholder="Heading..."
32
+ />
33
+ <textarea
34
+ value={subheading}
35
+ onChange={(e) => onChange("subheading", e.target.value)}
36
+ className={textareaClass}
37
+ placeholder="Subheading..."
38
+ />
39
+ </div>
40
+ );
41
+ };
42
+
43
+ export default HeadingSubheadingField;
@@ -32,13 +32,13 @@ export const ListField: React.FC<ListFieldProps> = ({
32
32
  };
33
33
 
34
34
  const inputClass = compact
35
- ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
35
+ ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded-md bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
36
36
  : "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
37
37
 
38
38
  return (
39
39
  <div className={compact ? "space-y-1.5" : "space-y-2"}>
40
40
  {items.length === 0 ? (
41
- <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-lg">
41
+ <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md">
42
42
  No items. Type below to add.
43
43
  </div>
44
44
  ) : (
@@ -16,7 +16,10 @@ export default function NumberField({
16
16
  error,
17
17
  disabled,
18
18
  }: NumberFieldComponentProps) {
19
- const isReadOnly = field.admin?.readOnly;
19
+ const isReadOnly =
20
+ typeof field.admin?.readOnly === "function"
21
+ ? false
22
+ : Boolean(field.admin?.readOnly);
20
23
 
21
24
  return (
22
25
  <FieldLayout
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useEffect } from "react";
2
2
  import { Search, Loader2, X } from "../ui/icons";
3
3
  import { apiGet, buildSearchQuery } from "../../lib/api";
4
+ import { EmptyState } from "../ui/EmptyState";
4
5
 
5
6
  interface RelationshipBlockFieldProps {
6
7
  relationTo?: string;
@@ -171,9 +172,7 @@ export const RelationshipBlockField: React.FC<RelationshipBlockFieldProps> = ({
171
172
  Loading...
172
173
  </div>
173
174
  ) : options.length === 0 ? (
174
- <div className="p-3 text-center text-sm text-[var(--kyro-text-muted)]">
175
- No results found
176
- </div>
175
+ <EmptyState title="No results found" />
177
176
  ) : (
178
177
  <div className="py-1">
179
178
  {options.map((opt) => (
@@ -1,6 +1,7 @@
1
- import { useEffect, useState, useRef } from "react";
1
+ import { useEffect, useState, useRef, useCallback } from "react";
2
2
  import { Search, X, ChevronDown, Loader2 } from "../ui/icons";
3
3
  import { apiGet, buildSearchQuery } from "../../lib/api";
4
+ import { EmptyState } from "../ui/EmptyState";
4
5
 
5
6
  interface RelationshipFieldProps {
6
7
  field: {
@@ -15,12 +16,33 @@ interface RelationshipFieldProps {
15
16
  placeholder?: string;
16
17
  };
17
18
  };
18
- value?: string | string[] | null;
19
- onChange?: (value: string | string[] | null) => void;
19
+ value?: unknown;
20
+ onChange?: (value: unknown) => void;
20
21
  error?: string;
21
22
  disabled?: boolean;
22
23
  }
23
24
 
25
+ interface ResolvedDoc {
26
+ id: string;
27
+ relationTo?: string;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ function getLabel(opt: Record<string, unknown>): string {
32
+ const mainTabs = opt?.mainTabs as Record<string, unknown> | undefined;
33
+
34
+ return (
35
+ (opt?.title as string) ||
36
+ (mainTabs?.title as string) ||
37
+ (opt?.name as string) ||
38
+ (opt?.label as string) ||
39
+ (opt?.email as string) ||
40
+ (opt?.filename as string) ||
41
+ (opt?.slug as string) ||
42
+ "Untitled"
43
+ );
44
+ }
45
+
24
46
  export function RelationshipField({
25
47
  field,
26
48
  value,
@@ -30,28 +52,85 @@ export function RelationshipField({
30
52
  }: RelationshipFieldProps) {
31
53
  const [isOpen, setIsOpen] = useState(false);
32
54
  const [search, setSearch] = useState("");
33
- const [options, setOptions] = useState<Record<string, unknown>[]>([]);
55
+ const [options, setOptions] = useState<ResolvedDoc[]>([]);
34
56
  const [loading, setLoading] = useState(false);
57
+ const [selectedDocs, setSelectedDocs] = useState<ResolvedDoc[]>([]);
58
+ const fetchedIdsRef = useRef<Set<string>>(new Set());
35
59
  const containerRef = useRef<HTMLDivElement>(null);
60
+ const onChangeRef = useRef<(value: unknown) => void>(() => {});
61
+ onChangeRef.current = onChange || (() => {});
36
62
 
37
63
  const isMultiple = field.hasMany;
38
64
  const relationTo = Array.isArray(field.relationTo)
39
65
  ? field.relationTo
40
66
  : [field.relationTo];
41
- const targetCollection = relationTo[0];
67
+ const isPolymorphic = relationTo.length > 1;
68
+ const [activeRelation, setActiveRelation] = useState(relationTo[0]);
69
+
70
+ const extractIds = useCallback((): string[] => {
71
+ if (!value) return [];
72
+ const items = isMultiple
73
+ ? Array.isArray(value) ? value : []
74
+ : value ? [value] : [];
75
+ return items.map((item) => {
76
+ if (typeof item === "object" && item !== null) {
77
+ return (item as { value?: string }).value || (item as { id?: string }).id || "";
78
+ }
79
+ return String(item);
80
+ }).filter(Boolean);
81
+ }, [value, isMultiple]);
82
+
83
+ const fetchSelectedDocs = useCallback((ids: string[]) => {
84
+ if (ids.length === 0) return;
85
+ ids.forEach((id) => {
86
+ if (fetchedIdsRef.current.has(id)) return;
87
+ fetchedIdsRef.current.add(id);
88
+ const rel = isPolymorphic
89
+ ? (() => {
90
+ if (!value) return activeRelation;
91
+ const items = isMultiple
92
+ ? Array.isArray(value) ? value : []
93
+ : [value];
94
+ const match = items.find((item) => {
95
+ if (typeof item === "object" && item !== null) {
96
+ return (item as { value?: string }).value === id || (item as { id?: string }).id === id;
97
+ }
98
+ return false;
99
+ });
100
+ return match && typeof match === "object" ? (match as { relationTo?: string }).relationTo || activeRelation : activeRelation;
101
+ })()
102
+ : activeRelation;
103
+ apiGet<Record<string, unknown>>(`/api/${rel}/${id}`)
104
+ .then((response) => {
105
+ const doc = (response as any).data || response;
106
+ if (!doc || typeof doc !== "object") return;
42
107
 
43
- const fetchOptions = (query: string = "") => {
108
+ setSelectedDocs((prev) => {
109
+ if (prev.some((d) => d.id === id)) return prev;
110
+ return [...prev, { ...doc, id: String((doc as any).id), relationTo: rel }];
111
+ });
112
+ })
113
+ .catch(() => {});
114
+ });
115
+ }, [isPolymorphic, value, activeRelation, isMultiple]);
116
+
117
+ useEffect(() => {
118
+ const ids = extractIds();
119
+ fetchSelectedDocs(ids);
120
+ }, [extractIds, fetchSelectedDocs]);
121
+
122
+ const fetchOptions = useCallback((query: string = "") => {
44
123
  setLoading(true);
45
124
  const searchFields = ["title", "name", "label", "email"];
46
- const url = `/api/${targetCollection}?${buildSearchQuery(query, searchFields)}`;
125
+ const url = `/api/${activeRelation}?${buildSearchQuery(query, searchFields)}`;
47
126
 
48
- apiGet(url)
127
+ apiGet<{ docs?: Record<string, unknown>[] }>(url)
49
128
  .then((data) => {
50
129
  setOptions((prev) => {
51
130
  const existingIds = new Set(prev.map((o) => o.id));
52
- const newDocs = (data.docs || []).filter(
53
- (d: Record<string, unknown>) => !existingIds.has(d.id as string),
54
- );
131
+ const newDocs: ResolvedDoc[] = (data.docs || []).filter(
132
+ (d) => !existingIds.has(d.id as string),
133
+ ).map((d) => ({ ...d, id: d.id as string }));
55
134
  return [...prev, ...newDocs];
56
135
  });
57
136
  setLoading(false);
@@ -59,39 +138,14 @@ const newDocs = (data.docs || []).filter(
59
138
  .catch(() => {
60
139
  setLoading(false);
61
140
  });
62
- };
63
-
64
- const fetchSelectedItems = () => {
65
- const items: (string | Record<string, unknown>)[] = isMultiple
66
- ? Array.isArray(value)
67
- ? value
68
- : []
69
- : value
70
- ? [value]
71
- : [];
72
- items.forEach((itemId) => {
73
- const id = typeof itemId === "object" ? itemId?.id : itemId;
74
- if (id && !options.some((o) => o.id === id)) {
75
- apiGet(`/api/${targetCollection}/${id}`)
76
- .then((doc) => {
77
- setOptions((prev) => [...prev, doc]);
78
- })
79
- .catch(() => {});
80
- }
81
- });
82
- };
83
-
84
- useEffect(() => {
85
- if (value) {
86
- fetchSelectedItems();
87
- }
88
- }, [value, targetCollection]);
141
+ }, [activeRelation]);
89
142
 
90
143
  useEffect(() => {
91
144
  if (isOpen) {
145
+ setOptions([]);
92
146
  fetchOptions(search);
93
147
  }
94
- }, [isOpen, targetCollection]);
148
+ }, [isOpen, activeRelation]);
95
149
 
96
150
  useEffect(() => {
97
151
  const handleClickOutside = (event: MouseEvent) => {
@@ -106,24 +160,14 @@ const fetchSelectedItems = () => {
106
160
  return () => document.removeEventListener("mousedown", handleClickOutside);
107
161
  }, []);
108
162
 
109
- const getLabel = (opt: Record<string, unknown>) => {
110
- return (
111
- opt?.title ||
112
- opt?.name ||
113
- opt?.label ||
114
- opt?.email ||
115
- opt?.filename ||
116
- opt?.slug ||
117
- String(opt?.id) ||
118
- "Untitled"
119
- );
120
- };
121
-
122
- const getValueId = (val: string | Record<string, unknown>) => {
123
- return val?.id || val;
163
+ const getValueId = (val: unknown): string => {
164
+ if (typeof val === "object" && val !== null) {
165
+ return (val as { value?: string }).value || (val as { id?: string }).id || "";
166
+ }
167
+ return String(val);
124
168
  };
125
169
 
126
- const isSelected = (opt: Record<string, unknown>) => {
170
+ const isSelected = (opt: Record<string, unknown>): boolean => {
127
171
  const optId = opt.id;
128
172
  if (!value) return false;
129
173
  if (isMultiple && Array.isArray(value)) {
@@ -135,60 +179,56 @@ const fetchSelectedItems = () => {
135
179
  const handleSelect = (opt: Record<string, unknown>) => {
136
180
  const optId = opt.id;
137
181
  if (isMultiple) {
138
- const current = Array.isArray(value) ? value : [];
182
+ const current: unknown[] = Array.isArray(value) ? value : [];
139
183
  if (isSelected(opt)) {
140
- onChange?.(current.filter((v) => getValueId(v) !== optId));
184
+ onChangeRef.current?.(current.filter((v) => getValueId(v) !== optId));
141
185
  } else {
142
- onChange?.([...current, optId]);
186
+ const newItem = isPolymorphic
187
+ ? { relationTo: activeRelation, value: optId }
188
+ : optId;
189
+ onChangeRef.current?.([...current, newItem]);
143
190
  }
144
191
  } else {
145
192
  if (isSelected(opt)) {
146
- onChange?.(null);
193
+ onChangeRef.current?.(null);
147
194
  } else {
148
- onChange?.(optId);
195
+ const newItem = isPolymorphic
196
+ ? { relationTo: activeRelation, value: optId }
197
+ : optId;
198
+ onChangeRef.current?.(newItem);
149
199
  setIsOpen(false);
150
200
  setSearch("");
151
201
  }
152
202
  }
153
203
  };
154
204
 
155
- const handleClear = () => {
156
- onChange?.(isMultiple ? [] : null);
157
- };
158
-
159
205
  const renderSelectedItems = () => {
160
206
  if (!value) return null;
161
207
 
162
- let items: (string | Record<string, unknown>)[];
163
- if (isMultiple) {
164
- items = Array.isArray(value) ? value : [];
165
- } else {
166
- items = value ? [value] : [];
167
- }
208
+ const items: unknown[] = isMultiple
209
+ ? Array.isArray(value) ? value : []
210
+ : value ? [value] : [];
168
211
 
169
212
  return (
170
213
  <div className="flex flex-wrap gap-1.5 mt-2">
171
- {items.map((item, idx) => {
172
- const rawId = typeof item === "object" ? item?.id || item : item;
173
- const opt = options.find((o) => o.id === rawId);
174
- const label = opt ? getLabel(opt) : String(rawId).slice(0, 8);
214
+ {items.map((item) => {
215
+ const rawId = getValueId(item);
216
+ const doc = selectedDocs.find((d) => d.id === rawId);
217
+ const label = doc ? getLabel(doc) : rawId.slice(0, 12);
218
+ const rel = isPolymorphic && doc ? doc.relationTo : null;
175
219
  return (
176
220
  <span
177
- key={idx}
221
+ key={rawId}
178
222
  className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-[var(--kyro-sidebar-active)]/10 text-[var(--kyro-sidebar-active)]"
179
223
  >
224
+ {rel && <span className="opacity-60 mr-0.5">{rel}:</span>}
180
225
  {label}
181
226
  {!disabled && (
182
227
  <button
183
228
  type="button"
184
229
  onClick={() => {
185
- if (isMultiple) {
186
- onChange?.(
187
- (items as (string | Record<string, unknown>)[]).filter((_: unknown, i: number) => i !== idx),
188
- );
189
- } else {
190
- onChange?.(null);
191
- }
230
+ const filtered = items.filter((value) => getValueId(value) !== rawId);
231
+ onChangeRef.current?.(isMultiple ? filtered : (filtered[0] ?? null));
192
232
  }}
193
233
  className="hover:opacity-70"
194
234
  >
@@ -213,6 +253,28 @@ onChange?.(
213
253
  </label>
214
254
  )}
215
255
  <div ref={containerRef} className="relative">
256
+ {isPolymorphic && (
257
+ <div className="flex gap-1 mb-1.5">
258
+ {relationTo.map((rel) => (
259
+ <button
260
+ key={rel}
261
+ type="button"
262
+ onClick={() => {
263
+ setActiveRelation(rel);
264
+ setOptions([]);
265
+ setSearch("");
266
+ }}
267
+ className={`px-2 py-0.5 text-[10px] font-bold rounded transition-colors ${
268
+ activeRelation === rel
269
+ ? "kyro-btn-primary"
270
+ : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-border)]"
271
+ }`}
272
+ >
273
+ {rel}
274
+ </button>
275
+ ))}
276
+ </div>
277
+ )}
216
278
  <div className="relative">
217
279
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
218
280
  <input
@@ -225,10 +287,15 @@ onChange?.(
225
287
  }}
226
288
  onFocus={() => setIsOpen(true)}
227
289
  placeholder={
228
- field.admin?.placeholder || `Search ${targetCollection}...`
290
+ field.admin?.placeholder || `Search ${activeRelation}...`
291
+ }
292
+ disabled={
293
+ disabled ||
294
+ (typeof field.admin?.readOnly === "function"
295
+ ? false
296
+ : Boolean(field.admin?.readOnly))
229
297
  }
230
- disabled={disabled || field.admin?.readOnly}
231
- className="w-full pl-9 pr-10 py-2 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent disabled:opacity-50"
298
+ className="w-full pl-9 pr-10 py-2 border border-[var(--kyro-border)] rounded-md bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent disabled:opacity-50"
232
299
  />
233
300
  <div className="absolute right-3 top-1/2 -translate-y-1/2">
234
301
  {loading ? (
@@ -242,15 +309,13 @@ onChange?.(
242
309
  </div>
243
310
 
244
311
  {isOpen && (
245
- <div className="absolute z-20 w-full mt-1 border border-[var(--kyro-border)] rounded-lg shadow-lg bg-[var(--kyro-surface)] max-h-64 overflow-auto">
312
+ <div className="relative z-20 w-full mt-1 border border-[var(--kyro-border)] rounded-lg shadow-lg bg-[var(--kyro-surface)] max-h-64 overflow-auto">
246
313
  {loading ? (
247
314
  <div className="p-4 text-center text-sm text-[var(--kyro-text-muted)]">
248
315
  Loading...
249
316
  </div>
250
317
  ) : options.length === 0 ? (
251
- <div className="p-4 text-center text-sm text-[var(--kyro-text-muted)]">
252
- No results found
253
- </div>
318
+ <EmptyState title="No results found" />
254
319
  ) : (
255
320
  <div className="py-1">
256
321
  {options.map((opt) => (
@@ -272,7 +337,7 @@ onChange?.(
272
337
  </span>
273
338
  )}
274
339
  </div>
275
- {opt.slug && (
340
+ {"slug" in opt && typeof opt.slug === "string" && (
276
341
  <div className="text-xs text-[var(--kyro-text-muted)]">
277
342
  {opt.slug}
278
343
  </div>