@kyro-cms/admin 0.1.6 → 0.1.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.
Files changed (179) hide show
  1. package/README.md +149 -51
  2. package/package.json +54 -5
  3. package/src/collections/auth/index.ts +2 -2
  4. package/src/collections/portfolio/index.ts +343 -0
  5. package/src/components/ActionBar.tsx +153 -16
  6. package/src/components/Admin.tsx +137 -28
  7. package/src/components/ApiExplorer.tsx +325 -0
  8. package/src/components/ApiKeysManager.tsx +563 -0
  9. package/src/components/AuditLogsPage.tsx +664 -0
  10. package/src/components/AutoForm.tsx +2155 -770
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +4 -4
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +200 -58
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +890 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +192 -54
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +206 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/ThemeProvider.tsx +8 -2
  26. package/src/components/UserManagement.tsx +204 -0
  27. package/src/components/VersionHistoryPanel.tsx +3 -3
  28. package/src/components/WebhookManager.tsx +608 -0
  29. package/src/components/blocks/AccordionBlock.tsx +65 -0
  30. package/src/components/blocks/ArrayBlock.tsx +84 -0
  31. package/src/components/blocks/BlockEditModal.tsx +363 -0
  32. package/src/components/blocks/ButtonBlock.tsx +64 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +114 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +93 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +63 -0
  38. package/src/components/blocks/HeadingBlock.tsx +59 -0
  39. package/src/components/blocks/HeroBlock.tsx +99 -0
  40. package/src/components/blocks/ImageBlock.tsx +82 -0
  41. package/src/components/blocks/LinkBlock.tsx +65 -0
  42. package/src/components/blocks/ListBlock.tsx +60 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +72 -0
  45. package/src/components/blocks/RichTextBlock.tsx +66 -0
  46. package/src/components/blocks/VStackBlock.tsx +61 -0
  47. package/src/components/blocks/VideoBlock.tsx +65 -0
  48. package/src/components/blocks/index.ts +10 -0
  49. package/src/components/fields/AccordionField.tsx +213 -0
  50. package/src/components/fields/ArrayField.tsx +241 -0
  51. package/src/components/fields/BlocksField.tsx +323 -0
  52. package/src/components/fields/ButtonField.tsx +53 -0
  53. package/src/components/fields/CheckboxField.tsx +18 -8
  54. package/src/components/fields/ChildrenField.tsx +48 -0
  55. package/src/components/fields/CodeField.tsx +294 -0
  56. package/src/components/fields/ColumnsField.tsx +137 -0
  57. package/src/components/fields/DateField.tsx +24 -12
  58. package/src/components/fields/EditorClient.tsx +537 -0
  59. package/src/components/fields/HeadingField.tsx +31 -0
  60. package/src/components/fields/HeroField.tsx +101 -0
  61. package/src/components/fields/JSONField.tsx +341 -0
  62. package/src/components/fields/LinkField.tsx +81 -0
  63. package/src/components/fields/ListField.tsx +74 -0
  64. package/src/components/fields/MarkdownField.tsx +260 -0
  65. package/src/components/fields/NumberField.tsx +25 -13
  66. package/src/components/fields/PortableTextField.tsx +155 -0
  67. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  68. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  69. package/src/components/fields/RelationshipField.tsx +278 -60
  70. package/src/components/fields/SelectField.tsx +28 -16
  71. package/src/components/fields/TextField.tsx +31 -15
  72. package/src/components/fields/UploadField.tsx +613 -0
  73. package/src/components/fields/VideoField.tsx +73 -0
  74. package/src/components/fields/extensions/blockComponents.tsx +247 -0
  75. package/src/components/fields/extensions/blocksStore.ts +273 -0
  76. package/src/components/fields/index.ts +24 -0
  77. package/src/components/index.ts +1 -2
  78. package/src/components/layout/Header.tsx +2 -2
  79. package/src/components/layout/Layout.tsx +3 -3
  80. package/src/components/ui/Badge.tsx +9 -4
  81. package/src/components/ui/BlockDrawer.tsx +79 -0
  82. package/src/components/ui/Button.tsx +1 -1
  83. package/src/components/ui/CommandPalette.tsx +362 -0
  84. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  85. package/src/components/ui/Dropdown.tsx +1 -1
  86. package/src/components/ui/Modal.tsx +37 -12
  87. package/src/components/ui/PromptModal.tsx +94 -0
  88. package/src/components/ui/SlidePanel.tsx +43 -16
  89. package/src/components/ui/Toast.tsx +80 -14
  90. package/src/env.d.ts +16 -0
  91. package/src/env.ts +20 -0
  92. package/src/index.ts +0 -1
  93. package/src/layouts/AdminLayout.astro +164 -170
  94. package/src/layouts/AuthLayout.astro +23 -6
  95. package/src/lib/MediaService.ts +541 -0
  96. package/src/lib/api.ts +163 -0
  97. package/src/lib/auth/sqlite-adapter.ts +319 -0
  98. package/src/lib/config.ts +23 -7
  99. package/src/lib/dataStore.ts +188 -73
  100. package/src/lib/date-utils.ts +69 -0
  101. package/src/lib/db/adapter.ts +54 -0
  102. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  103. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  104. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  105. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  106. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  107. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  108. package/src/lib/db/index.ts +449 -0
  109. package/src/lib/db/mongodb-adapter.ts +207 -0
  110. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  111. package/src/lib/db/schema/mysql-auth.ts +113 -0
  112. package/src/lib/db/schema/mysql-content.ts +20 -0
  113. package/src/lib/db/schema/postgres-auth.ts +116 -0
  114. package/src/lib/db/schema/postgres-content.ts +35 -0
  115. package/src/lib/db/schema/postgres-media.ts +52 -0
  116. package/src/lib/db/schema/postgres-settings.ts +11 -0
  117. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  118. package/src/lib/db/schema/sqlite-content.ts +20 -0
  119. package/src/lib/db/version-adapter.ts +248 -0
  120. package/src/lib/graphql/index.ts +1 -0
  121. package/src/lib/graphql/schema.ts +443 -0
  122. package/src/lib/i18n.tsx +353 -0
  123. package/src/lib/rate-limit.ts +267 -0
  124. package/src/lib/slugify.ts +15 -0
  125. package/src/lib/storage.ts +374 -0
  126. package/src/lib/store.ts +85 -0
  127. package/src/lib/validation.ts +250 -0
  128. package/src/middleware.ts +70 -11
  129. package/src/pages/[collection]/[id].astro +178 -122
  130. package/src/pages/[collection]/index.astro +24 -156
  131. package/src/pages/admin/api-explorer.astro +98 -0
  132. package/src/pages/admin/graphql-explorer.astro +40 -0
  133. package/src/pages/admin/graphql.astro +97 -0
  134. package/src/pages/admin/index.astro +200 -139
  135. package/src/pages/admin/keys.astro +8 -0
  136. package/src/pages/admin/rest-playground.astro +44 -0
  137. package/src/pages/admin/webhooks.astro +8 -0
  138. package/src/pages/api/[collection]/[id]/publish.ts +52 -0
  139. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  140. package/src/pages/api/[collection]/[id]/versions.ts +66 -0
  141. package/src/pages/api/[collection]/[id].ts +114 -159
  142. package/src/pages/api/[collection]/index.ts +150 -230
  143. package/src/pages/api/auth/[id].ts +48 -69
  144. package/src/pages/api/auth/audit-logs.ts +20 -43
  145. package/src/pages/api/auth/login.ts +159 -45
  146. package/src/pages/api/auth/logout.ts +42 -24
  147. package/src/pages/api/auth/refresh.ts +119 -0
  148. package/src/pages/api/auth/register.ts +110 -40
  149. package/src/pages/api/auth/users.ts +22 -97
  150. package/src/pages/api/collections.ts +59 -0
  151. package/src/pages/api/globals/[slug]/test.ts +172 -0
  152. package/src/pages/api/globals/[slug].ts +42 -0
  153. package/src/pages/api/graphql.ts +90 -0
  154. package/src/pages/api/health.ts +417 -40
  155. package/src/pages/api/keys/[id].ts +26 -0
  156. package/src/pages/api/keys/index.ts +75 -0
  157. package/src/pages/api/media/[id].ts +309 -0
  158. package/src/pages/api/media/folders.ts +609 -0
  159. package/src/pages/api/media/index.ts +146 -0
  160. package/src/pages/api/media/resize.ts +267 -0
  161. package/src/pages/api/search.ts +82 -0
  162. package/src/pages/api/slug-availability.ts +70 -0
  163. package/src/pages/api/storage-config.ts +20 -0
  164. package/src/pages/api/storage-status.ts +206 -0
  165. package/src/pages/api/upload.ts +334 -0
  166. package/src/pages/api/webhooks/index.ts +71 -0
  167. package/src/pages/audit/index.astro +2 -104
  168. package/src/pages/login.astro +11 -11
  169. package/src/pages/media.astro +10 -0
  170. package/src/pages/preview/[collection]/[id].astro +178 -0
  171. package/src/pages/register.astro +13 -13
  172. package/src/pages/roles/index.astro +21 -21
  173. package/src/pages/settings/[slug].astro +162 -0
  174. package/src/pages/settings/index.astro +9 -0
  175. package/src/pages/users/[id].astro +29 -21
  176. package/src/pages/users/index.astro +22 -17
  177. package/src/pages/users/new.astro +18 -17
  178. package/src/styles/main.css +563 -128
  179. package/src/components/layout/Sidebar.tsx +0 -497
@@ -0,0 +1,233 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Search, Loader2, X } from "lucide-react";
3
+
4
+ interface RelationshipBlockFieldProps {
5
+ relationTo?: string;
6
+ hasMany?: boolean;
7
+ selectedIds?: string[];
8
+ selectedId?: string;
9
+ labelField?: string;
10
+ onChange: (field: string, value: any) => void;
11
+ compact?: boolean;
12
+ }
13
+
14
+ export const RelationshipBlockField: React.FC<RelationshipBlockFieldProps> = ({
15
+ relationTo = "pages",
16
+ hasMany = false,
17
+ selectedIds = [],
18
+ selectedId,
19
+ labelField = "title",
20
+ onChange,
21
+ compact = false,
22
+ }) => {
23
+ const [isOpen, setIsOpen] = useState(false);
24
+ const [search, setSearch] = useState("");
25
+ const [options, setOptions] = useState<any[]>([]);
26
+ const [loading, setLoading] = useState(false);
27
+ const [collections, setCollections] = useState<string[]>([]);
28
+ const [loadingCollections, setLoadingCollections] = useState(true);
29
+
30
+ useEffect(() => {
31
+ fetch("/api/collections", { credentials: "include" })
32
+ .then((res) => res.json())
33
+ .then((data) => {
34
+ setCollections(
35
+ (data.collections || []).map((c: any) => c.slug || c.name || c),
36
+ );
37
+ setLoadingCollections(false);
38
+ })
39
+ .catch(() => setLoadingCollections(false));
40
+ }, []);
41
+
42
+ const fetchOptions = (query: string = "") => {
43
+ setLoading(true);
44
+ const url = query
45
+ ? `/api/${relationTo}?where[${labelField}][contains]=${encodeURIComponent(query)}&limit=20`
46
+ : `/api/${relationTo}?limit=20`;
47
+
48
+ fetch(url, { credentials: "include" })
49
+ .then((res) => res.json())
50
+ .then((data) => {
51
+ setOptions(data.docs || []);
52
+ setLoading(false);
53
+ })
54
+ .catch(() => setLoading(false));
55
+ };
56
+
57
+ useEffect(() => {
58
+ if (isOpen) fetchOptions(search);
59
+ }, [isOpen, search, relationTo, labelField]);
60
+
61
+ const getLabel = (opt: any) => {
62
+ return (
63
+ opt?.[labelField] ||
64
+ opt?.title ||
65
+ opt?.name ||
66
+ opt?.label ||
67
+ opt?.filename ||
68
+ opt?.slug ||
69
+ opt?.id ||
70
+ "Untitled"
71
+ );
72
+ };
73
+
74
+ const activeIds = hasMany ? selectedIds : selectedId ? [selectedId] : [];
75
+
76
+ const handleSelect = (opt: any) => {
77
+ if (hasMany) {
78
+ if (activeIds.includes(opt.id)) {
79
+ onChange(
80
+ "selectedIds",
81
+ activeIds.filter((id: string) => id !== opt.id),
82
+ );
83
+ } else {
84
+ onChange("selectedIds", [...activeIds, opt.id]);
85
+ }
86
+ } else {
87
+ onChange("selectedId", opt.id);
88
+ onChange("selectedIds", [opt.id]);
89
+ setIsOpen(false);
90
+ }
91
+ };
92
+
93
+ const isSelected = (optId: string) => activeIds.includes(optId);
94
+
95
+ const inputClass = compact
96
+ ? "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"
97
+ : "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";
98
+
99
+ const selectClass = compact
100
+ ? "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"
101
+ : "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";
102
+
103
+ return (
104
+ <div className={compact ? "space-y-2" : "space-y-4"}>
105
+ <div className={compact ? "flex items-center gap-2" : "space-y-3"}>
106
+ {loadingCollections ? (
107
+ <div className={selectClass + " text-[var(--kyro-text-muted)]"}>
108
+ Loading...
109
+ </div>
110
+ ) : (
111
+ <select
112
+ value={relationTo}
113
+ onChange={(e) => onChange("relationTo", e.target.value)}
114
+ className={selectClass}
115
+ >
116
+ <option value="">Select collection...</option>
117
+ {collections.map((col) => (
118
+ <option key={col} value={col}>
119
+ {col}
120
+ </option>
121
+ ))}
122
+ </select>
123
+ )}
124
+
125
+ {!compact && (
126
+ <label className="flex items-center gap-2 cursor-pointer">
127
+ <input
128
+ type="checkbox"
129
+ checked={hasMany}
130
+ onChange={(e) => onChange("hasMany", e.target.checked)}
131
+ className="w-4 h-4 rounded border-[var(--kyro-border)] focus:ring-[var(--kyro-sidebar-active)] focus:ring-offset-0"
132
+ />
133
+ <span className="text-sm text-[var(--kyro-text-primary)]">
134
+ Allow multiple
135
+ </span>
136
+ </label>
137
+ )}
138
+ </div>
139
+
140
+ <div className="relative">
141
+ <div className="relative">
142
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
143
+ <input
144
+ type="text"
145
+ value={search}
146
+ onChange={(e) => {
147
+ setSearch(e.target.value);
148
+ setIsOpen(true);
149
+ }}
150
+ onFocus={() => setIsOpen(true)}
151
+ onBlur={() => setTimeout(() => setIsOpen(false), 200)}
152
+ placeholder={`Search ${relationTo}...`}
153
+ className={`${inputClass} pl-9`}
154
+ />
155
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
156
+ {loading && (
157
+ <Loader2 className="w-4 h-4 text-[var(--kyro-text-muted)] animate-spin" />
158
+ )}
159
+ </div>
160
+ </div>
161
+
162
+ {isOpen && (
163
+ <div className="absolute z-20 w-full mt-1 border border-[var(--kyro-border)] rounded-lg shadow-lg bg-[var(--kyro-surface)] max-h-48 overflow-auto">
164
+ {loading ? (
165
+ <div className="p-3 text-center text-sm text-[var(--kyro-text-muted)]">
166
+ Loading...
167
+ </div>
168
+ ) : options.length === 0 ? (
169
+ <div className="p-3 text-center text-sm text-[var(--kyro-text-muted)]">
170
+ No results found
171
+ </div>
172
+ ) : (
173
+ <div className="py-1">
174
+ {options.map((opt) => (
175
+ <button
176
+ key={opt.id}
177
+ type="button"
178
+ onMouseDown={(e) => e.preventDefault()}
179
+ onClick={() => handleSelect(opt)}
180
+ className={`w-full px-3 py-2 text-left text-sm hover:bg-[var(--kyro-surface-accent)] transition-colors flex items-center justify-between ${
181
+ isSelected(opt.id)
182
+ ? "bg-[var(--kyro-sidebar-active)]/10 text-[var(--kyro-sidebar-active)]"
183
+ : "text-[var(--kyro-text-primary)]"
184
+ }`}
185
+ >
186
+ <span>{getLabel(opt)}</span>
187
+ {isSelected(opt.id) && <span>✓</span>}
188
+ </button>
189
+ ))}
190
+ </div>
191
+ )}
192
+ </div>
193
+ )}
194
+ </div>
195
+
196
+ {activeIds.length > 0 && (
197
+ <div className="flex flex-wrap gap-1.5">
198
+ {activeIds.map((id: string) => {
199
+ const opt = options.find((o) => o.id === id) || { id };
200
+ return (
201
+ <span
202
+ key={id}
203
+ className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-md bg-[var(--kyro-sidebar-active)]/10 text-[var(--kyro-sidebar-active)]"
204
+ >
205
+ {getLabel(opt)}
206
+ <button
207
+ type="button"
208
+ onMouseDown={(e) => e.preventDefault()}
209
+ onClick={() => {
210
+ if (hasMany) {
211
+ onChange(
212
+ "selectedIds",
213
+ activeIds.filter((sid: string) => sid !== id),
214
+ );
215
+ } else {
216
+ onChange("selectedId", null);
217
+ onChange("selectedIds", []);
218
+ }
219
+ }}
220
+ className="hover:opacity-70"
221
+ >
222
+ <X className="w-3 h-3" />
223
+ </button>
224
+ </span>
225
+ );
226
+ })}
227
+ </div>
228
+ )}
229
+ </div>
230
+ );
231
+ };
232
+
233
+ export default RelationshipBlockField;
@@ -1,87 +1,305 @@
1
- import { useState } from 'react';
2
- import type { RelationshipField as RelationshipFieldType } from '@kyro-cms/core';
1
+ import { useEffect, useState, useRef } from "react";
2
+ import { Search, X, ChevronDown, Loader2 } from "lucide-react";
3
3
 
4
- interface RelationshipFieldComponentProps {
5
- field: RelationshipFieldType;
6
- value?: string | string[];
7
- onChange?: (value: string | string[]) => void;
4
+ interface RelationshipFieldProps {
5
+ field: {
6
+ name: string;
7
+ label?: string;
8
+ relationTo: string | string[];
9
+ hasMany?: boolean;
10
+ required?: boolean;
11
+ admin?: {
12
+ description?: string;
13
+ readOnly?: boolean;
14
+ placeholder?: string;
15
+ };
16
+ };
17
+ value?: string | string[] | null;
18
+ onChange?: (value: string | string[] | null) => void;
8
19
  error?: string;
9
20
  disabled?: boolean;
10
21
  }
11
22
 
12
- export default function RelationshipField({ field, value, onChange, error, disabled }: RelationshipFieldComponentProps) {
13
- const [searchQuery, setSearchQuery] = useState('');
23
+ export function RelationshipField({
24
+ field,
25
+ value,
26
+ onChange,
27
+ error,
28
+ disabled,
29
+ }: RelationshipFieldProps) {
14
30
  const [isOpen, setIsOpen] = useState(false);
31
+ const [search, setSearch] = useState("");
32
+ const [options, setOptions] = useState<any[]>([]);
33
+ const [loading, setLoading] = useState(false);
34
+ const containerRef = useRef<HTMLDivElement>(null);
15
35
 
16
- const relationTo = Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo];
36
+ const isMultiple = field.hasMany;
37
+ const relationTo = Array.isArray(field.relationTo)
38
+ ? field.relationTo
39
+ : [field.relationTo];
40
+ const targetCollection = relationTo[0];
41
+
42
+ const fetchOptions = (query: string = "") => {
43
+ setLoading(true);
44
+ const searchFields = ["title", "name", "label", "email"];
45
+ const searchQuery = searchFields
46
+ .map((f) => `where[${f}][contains]=${encodeURIComponent(query)}`)
47
+ .join("&");
48
+ const url = query
49
+ ? `/api/${targetCollection}?${searchQuery}&limit=50`
50
+ : `/api/${targetCollection}?limit=50`;
51
+
52
+ fetch(url, { credentials: "include" })
53
+ .then((res) => res.json())
54
+ .then((data) => {
55
+ setOptions((prev) => {
56
+ const existingIds = new Set(prev.map((o) => o.id));
57
+ const newDocs = (data.docs || []).filter(
58
+ (d: any) => !existingIds.has(d.id),
59
+ );
60
+ return [...prev, ...newDocs];
61
+ });
62
+ setLoading(false);
63
+ })
64
+ .catch(() => {
65
+ setLoading(false);
66
+ });
67
+ };
68
+
69
+ const fetchSelectedItems = () => {
70
+ const items: any[] = isMultiple
71
+ ? Array.isArray(value)
72
+ ? value
73
+ : []
74
+ : value
75
+ ? [value]
76
+ : [];
77
+ items.forEach((itemId) => {
78
+ const id = typeof itemId === "object" ? itemId?.id : itemId;
79
+ if (id && !options.some((o) => o.id === id)) {
80
+ fetch(`/api/${targetCollection}/${id}`, { credentials: "include" })
81
+ .then((res) => res.json())
82
+ .then((doc) => {
83
+ setOptions((prev) => [...prev, doc]);
84
+ })
85
+ .catch(() => {});
86
+ }
87
+ });
88
+ };
89
+
90
+ useEffect(() => {
91
+ if (value) {
92
+ fetchSelectedItems();
93
+ }
94
+ }, [value, targetCollection]);
95
+
96
+ useEffect(() => {
97
+ if (isOpen) {
98
+ fetchOptions(search);
99
+ }
100
+ }, [isOpen, targetCollection]);
101
+
102
+ useEffect(() => {
103
+ const handleClickOutside = (event: MouseEvent) => {
104
+ if (
105
+ containerRef.current &&
106
+ !containerRef.current.contains(event.target as Node)
107
+ ) {
108
+ setIsOpen(false);
109
+ }
110
+ };
111
+ document.addEventListener("mousedown", handleClickOutside);
112
+ return () => document.removeEventListener("mousedown", handleClickOutside);
113
+ }, []);
114
+
115
+ const getLabel = (opt: any) => {
116
+ return (
117
+ opt?.title ||
118
+ opt?.name ||
119
+ opt?.label ||
120
+ opt?.email ||
121
+ opt?.filename ||
122
+ opt?.slug ||
123
+ String(opt?.id) ||
124
+ "Untitled"
125
+ );
126
+ };
127
+
128
+ const getValueId = (val: any) => {
129
+ return val?.id || val;
130
+ };
131
+
132
+ const isSelected = (opt: any) => {
133
+ const optId = opt.id;
134
+ if (!value) return false;
135
+ if (isMultiple && Array.isArray(value)) {
136
+ return value.some((v) => getValueId(v) === optId);
137
+ }
138
+ return getValueId(value) === optId;
139
+ };
140
+
141
+ const handleSelect = (opt: any) => {
142
+ const optId = opt.id;
143
+ if (isMultiple) {
144
+ const current = Array.isArray(value) ? value : [];
145
+ if (isSelected(opt)) {
146
+ onChange?.(current.filter((v) => getValueId(v) !== optId));
147
+ } else {
148
+ onChange?.([...current, optId]);
149
+ }
150
+ } else {
151
+ if (isSelected(opt)) {
152
+ onChange?.(null);
153
+ } else {
154
+ onChange?.(optId);
155
+ setIsOpen(false);
156
+ setSearch("");
157
+ }
158
+ }
159
+ };
160
+
161
+ const handleClear = () => {
162
+ onChange?.(isMultiple ? [] : null);
163
+ };
164
+
165
+ const renderSelectedItems = () => {
166
+ if (!value) return null;
167
+
168
+ let items: any[];
169
+ if (isMultiple) {
170
+ items = Array.isArray(value) ? value : [];
171
+ } else {
172
+ items = value ? [value] : [];
173
+ }
174
+
175
+ return (
176
+ <div className="flex flex-wrap gap-1.5 mt-2">
177
+ {items.map((item, idx) => {
178
+ const rawId = typeof item === "object" ? item?.id || item : item;
179
+ const opt = options.find((o) => o.id === rawId);
180
+ const label = opt ? getLabel(opt) : String(rawId).slice(0, 8);
181
+ return (
182
+ <span
183
+ key={idx}
184
+ 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)]"
185
+ >
186
+ {label}
187
+ {!disabled && (
188
+ <button
189
+ type="button"
190
+ onClick={() => {
191
+ if (isMultiple) {
192
+ onChange?.(
193
+ items.filter((_: any, i: number) => i !== idx),
194
+ );
195
+ } else {
196
+ onChange?.(null);
197
+ }
198
+ }}
199
+ className="hover:opacity-70"
200
+ >
201
+ <X className="w-3 h-3" />
202
+ </button>
203
+ )}
204
+ </span>
205
+ );
206
+ })}
207
+ </div>
208
+ );
209
+ };
17
210
 
18
211
  return (
19
- <div className="space-y-1">
212
+ <div className="space-y-1.5">
20
213
  {field.label && (
21
- <label className="block text-sm font-medium text-gray-700">
214
+ <label className="block text-sm font-medium text-[var(--kyro-text-primary)]">
22
215
  {field.label}
23
- {field.required && <span className="text-red-500 ml-1">*</span>}
216
+ {field.required && (
217
+ <span className="text-[var(--kyro-error)] ml-1">*</span>
218
+ )}
24
219
  </label>
25
220
  )}
26
- <div className="relative">
27
- <input
28
- type="text"
29
- value={searchQuery}
30
- onChange={(e) => {
31
- setSearchQuery(e.target.value);
32
- setIsOpen(true);
33
- }}
34
- onFocus={() => setIsOpen(true)}
35
- onBlur={() => setTimeout(() => setIsOpen(false), 200)}
36
- placeholder={`Search ${relationTo.join(' or ')}...`}
37
- disabled={disabled || field.admin?.readOnly}
38
- className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:border-blue-500 focus:ring-blue-500"
39
- />
40
-
41
- {isOpen && (
42
- <div className="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-auto">
43
- <div className="p-2 text-sm text-gray-500 text-center">
44
- Search results will appear here
45
- </div>
46
- {/* TODO: Implement actual search with API integration */}
221
+ <div ref={containerRef} className="relative">
222
+ <div className="relative">
223
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
224
+ <input
225
+ type="text"
226
+ value={search}
227
+ onChange={(e) => {
228
+ setSearch(e.target.value);
229
+ setIsOpen(true);
230
+ fetchOptions(e.target.value);
231
+ }}
232
+ onFocus={() => setIsOpen(true)}
233
+ placeholder={
234
+ field.admin?.placeholder || `Search ${targetCollection}...`
235
+ }
236
+ disabled={disabled || field.admin?.readOnly}
237
+ 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"
238
+ />
239
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
240
+ {loading ? (
241
+ <Loader2 className="w-4 h-4 text-[var(--kyro-text-muted)] animate-spin" />
242
+ ) : (
243
+ <ChevronDown
244
+ className={`w-4 h-4 text-[var(--kyro-text-muted)] transition-transform ${isOpen ? "rotate-180" : ""}`}
245
+ />
246
+ )}
47
247
  </div>
48
- )}
248
+ </div>
49
249
 
50
- {/* Selected value display */}
51
- {value && (
52
- <div className="mt-2 flex flex-wrap gap-2">
53
- {(Array.isArray(value) ? value : [value]).map((v, i) => (
54
- <span
55
- key={i}
56
- className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-700 text-sm rounded-md"
57
- >
58
- {v}
59
- {!disabled && !field.admin?.readOnly && (
250
+ {isOpen && (
251
+ <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">
252
+ {loading ? (
253
+ <div className="p-4 text-center text-sm text-[var(--kyro-text-muted)]">
254
+ Loading...
255
+ </div>
256
+ ) : options.length === 0 ? (
257
+ <div className="p-4 text-center text-sm text-[var(--kyro-text-muted)]">
258
+ No results found
259
+ </div>
260
+ ) : (
261
+ <div className="py-1">
262
+ {options.map((opt) => (
60
263
  <button
264
+ key={opt.id}
61
265
  type="button"
62
- onClick={() => {
63
- if (Array.isArray(value)) {
64
- onChange?.(value.filter((_, idx) => idx !== i));
65
- } else {
66
- onChange?.('');
67
- }
68
- }}
69
- className="ml-1 text-blue-500 hover:text-blue-700"
266
+ onClick={() => handleSelect(opt)}
267
+ className={`w-full px-3 py-2 text-left text-sm hover:bg-[var(--kyro-surface-accent)] transition-colors ${
268
+ isSelected(opt)
269
+ ? "bg-[var(--kyro-sidebar-active)]/10 text-[var(--kyro-sidebar-active)]"
270
+ : "text-[var(--kyro-text-primary)]"
271
+ }`}
70
272
  >
71
- ×
273
+ <div className="flex items-center justify-between">
274
+ <span>{getLabel(opt)}</span>
275
+ {isSelected(opt) && (
276
+ <span className="text-[var(--kyro-sidebar-active)]">
277
+
278
+ </span>
279
+ )}
280
+ </div>
281
+ {opt.slug && (
282
+ <div className="text-xs text-[var(--kyro-text-muted)]">
283
+ {opt.slug}
284
+ </div>
285
+ )}
72
286
  </button>
73
- )}
74
- </span>
75
- ))}
287
+ ))}
288
+ </div>
289
+ )}
76
290
  </div>
77
291
  )}
292
+
293
+ {renderSelectedItems()}
78
294
  </div>
79
295
  {field.admin?.description && !error && (
80
- <p className="text-xs text-gray-500">{field.admin.description}</p>
81
- )}
82
- {error && (
83
- <p className="text-xs text-red-600">{error}</p>
296
+ <p className="text-xs text-[var(--kyro-text-muted)]">
297
+ {field.admin.description}
298
+ </p>
84
299
  )}
300
+ {error && <p className="text-xs text-[var(--kyro-error)]">{error}</p>}
85
301
  </div>
86
302
  );
87
303
  }
304
+
305
+ export default RelationshipField;
@@ -1,4 +1,4 @@
1
- import type { SelectField as SelectFieldType } from '@kyro-cms/core';
1
+ import type { SelectField as SelectFieldType } from "@kyro-cms/core/client";
2
2
 
3
3
  interface SelectFieldComponentProps {
4
4
  field: SelectFieldType;
@@ -8,20 +8,34 @@ interface SelectFieldComponentProps {
8
8
  disabled?: boolean;
9
9
  }
10
10
 
11
- export default function SelectField({ field, value, onChange, error, disabled }: SelectFieldComponentProps) {
11
+ export default function SelectField({
12
+ field,
13
+ value,
14
+ onChange,
15
+ error,
16
+ disabled,
17
+ }: SelectFieldComponentProps) {
12
18
  return (
13
19
  <div className="space-y-1">
14
20
  {field.label && (
15
- <label className="block text-sm font-medium text-gray-700">
21
+ <label className="block text-sm font-medium text-[var(--kyro-text-primary)]">
16
22
  {field.label}
17
- {field.required && <span className="text-red-500 ml-1">*</span>}
23
+ {field.required && (
24
+ <span className="text-[var(--kyro-error)] ml-1">*</span>
25
+ )}
18
26
  </label>
19
27
  )}
20
28
  <select
21
- value={field.hasMany ? (Array.isArray(value) ? value.join(',') : '') : (value || '')}
29
+ value={
30
+ field.hasMany
31
+ ? Array.isArray(value)
32
+ ? value.join(",")
33
+ : ""
34
+ : value || ""
35
+ }
22
36
  onChange={(e) => {
23
37
  if (field.hasMany) {
24
- const selected = e.target.value ? e.target.value.split(',') : [];
38
+ const selected = e.target.value ? e.target.value.split(",") : [];
25
39
  onChange?.(selected);
26
40
  } else {
27
41
  onChange?.(e.target.value);
@@ -32,13 +46,11 @@ export default function SelectField({ field, value, onChange, error, disabled }:
32
46
  required={field.required}
33
47
  className={`w-full px-3 py-2 border rounded-md text-sm transition-colors ${
34
48
  error
35
- ? 'border-red-300 focus:border-red-500 focus:ring-red-500'
36
- : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
37
- } ${disabled || field.admin?.readOnly ? 'bg-gray-50 text-gray-500' : 'bg-white'}`}
49
+ ? "border-[var(--kyro-error)] focus:border-[var(--kyro-error)] focus:ring-[var(--kyro-error)]"
50
+ : "border-[var(--kyro-border)] focus:border-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)]"
51
+ } ${disabled || field.admin?.readOnly ? "bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-muted)]" : "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"}`}
38
52
  >
39
- {!field.required && (
40
- <option value="">Select...</option>
41
- )}
53
+ {!field.required && <option value="">Select...</option>}
42
54
  {field.options.map((option) => (
43
55
  <option key={option.value} value={option.value}>
44
56
  {option.label}
@@ -46,11 +58,11 @@ export default function SelectField({ field, value, onChange, error, disabled }:
46
58
  ))}
47
59
  </select>
48
60
  {field.admin?.description && !error && (
49
- <p className="text-xs text-gray-500">{field.admin.description}</p>
50
- )}
51
- {error && (
52
- <p className="text-xs text-red-600">{error}</p>
61
+ <p className="text-xs text-[var(--kyro-text-secondary)]">
62
+ {field.admin.description}
63
+ </p>
53
64
  )}
65
+ {error && <p className="text-xs text-[var(--kyro-error)]">{error}</p>}
54
66
  </div>
55
67
  );
56
68
  }