@kyro-cms/admin 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +149 -51
  2. package/package.json +53 -6
  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 +136 -27
  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 +1417 -661
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +3 -3
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +199 -57
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +786 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +191 -53
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +149 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/UserManagement.tsx +204 -0
  26. package/src/components/VersionHistoryPanel.tsx +3 -3
  27. package/src/components/WebhookManager.tsx +608 -0
  28. package/src/components/blocks/AccordionBlock.tsx +97 -0
  29. package/src/components/blocks/ArrayBlock.tsx +75 -0
  30. package/src/components/blocks/BlockEditModal.MARKER +12 -0
  31. package/src/components/blocks/BlockEditModal.tsx +774 -0
  32. package/src/components/blocks/ButtonBlock.tsx +165 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +66 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +151 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +64 -0
  38. package/src/components/blocks/HeadingBlock.tsx +81 -0
  39. package/src/components/blocks/HeroBlock.tsx +157 -0
  40. package/src/components/blocks/ImageBlock.tsx +83 -0
  41. package/src/components/blocks/LinkBlock.tsx +71 -0
  42. package/src/components/blocks/ListBlock.tsx +39 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +279 -0
  45. package/src/components/blocks/VStackBlock.tsx +75 -0
  46. package/src/components/blocks/VideoBlock.tsx +45 -0
  47. package/src/components/blocks/index.ts +10 -0
  48. package/src/components/fields/BlocksField.tsx +323 -0
  49. package/src/components/fields/CheckboxField.tsx +15 -9
  50. package/src/components/fields/CodeField.tsx +234 -0
  51. package/src/components/fields/DateField.tsx +38 -11
  52. package/src/components/fields/EditorClient.tsx +271 -0
  53. package/src/components/fields/FileField.tsx +390 -0
  54. package/src/components/fields/HybridContentField.tsx +109 -0
  55. package/src/components/fields/ImageField.tsx +429 -0
  56. package/src/components/fields/JSONField.tsx +361 -0
  57. package/src/components/fields/MarkdownField.tsx +282 -0
  58. package/src/components/fields/NumberField.tsx +42 -12
  59. package/src/components/fields/PortableTextField.tsx +143 -0
  60. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  61. package/src/components/fields/RelationshipField.tsx +231 -59
  62. package/src/components/fields/SelectField.tsx +25 -15
  63. package/src/components/fields/TextField.tsx +45 -14
  64. package/src/components/fields/extensions/blockComponents.tsx +237 -0
  65. package/src/components/fields/extensions/blocksStore.ts +273 -0
  66. package/src/components/fields/index.ts +13 -0
  67. package/src/components/index.ts +1 -2
  68. package/src/components/layout/Header.tsx +2 -2
  69. package/src/components/layout/Layout.tsx +2 -2
  70. package/src/components/ui/Badge.tsx +9 -4
  71. package/src/components/ui/BlockDrawer.tsx +79 -0
  72. package/src/components/ui/Button.tsx +1 -1
  73. package/src/components/ui/CommandPalette.tsx +362 -0
  74. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  75. package/src/components/ui/Dropdown.tsx +1 -1
  76. package/src/components/ui/Modal.tsx +37 -12
  77. package/src/components/ui/PromptModal.tsx +94 -0
  78. package/src/components/ui/SlidePanel.tsx +43 -16
  79. package/src/components/ui/Toast.tsx +80 -14
  80. package/src/env.d.ts +16 -0
  81. package/src/env.ts +20 -0
  82. package/src/index.ts +0 -1
  83. package/src/layouts/AdminLayout.astro +164 -170
  84. package/src/layouts/AuthLayout.astro +23 -6
  85. package/src/lib/MediaService.ts +541 -0
  86. package/src/lib/auth/sqlite-adapter.ts +319 -0
  87. package/src/lib/config.ts +22 -6
  88. package/src/lib/dataStore.ts +132 -74
  89. package/src/lib/db/adapter.ts +54 -0
  90. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  91. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  92. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  93. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  94. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  95. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  96. package/src/lib/db/index.ts +449 -0
  97. package/src/lib/db/mongodb-adapter.ts +207 -0
  98. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  99. package/src/lib/db/schema/mysql-auth.ts +113 -0
  100. package/src/lib/db/schema/mysql-content.ts +20 -0
  101. package/src/lib/db/schema/postgres-auth.ts +116 -0
  102. package/src/lib/db/schema/postgres-content.ts +35 -0
  103. package/src/lib/db/schema/postgres-media.ts +52 -0
  104. package/src/lib/db/schema/postgres-settings.ts +11 -0
  105. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  106. package/src/lib/db/schema/sqlite-content.ts +20 -0
  107. package/src/lib/graphql/index.ts +1 -0
  108. package/src/lib/graphql/schema.ts +443 -0
  109. package/src/lib/rate-limit.ts +267 -0
  110. package/src/lib/storage.ts +374 -0
  111. package/src/lib/store.ts +85 -0
  112. package/src/middleware.ts +70 -11
  113. package/src/pages/[collection]/[id].astro +178 -122
  114. package/src/pages/[collection]/index.astro +24 -156
  115. package/src/pages/admin/api-explorer.astro +98 -0
  116. package/src/pages/admin/graphql-explorer.astro +40 -0
  117. package/src/pages/admin/graphql.astro +97 -0
  118. package/src/pages/admin/index.astro +200 -139
  119. package/src/pages/admin/keys.astro +8 -0
  120. package/src/pages/admin/rest-playground.astro +44 -0
  121. package/src/pages/admin/webhooks.astro +8 -0
  122. package/src/pages/api/[collection]/[id]/publish.ts +44 -0
  123. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  124. package/src/pages/api/[collection]/[id]/versions.ts +36 -0
  125. package/src/pages/api/[collection]/[id].ts +102 -159
  126. package/src/pages/api/[collection]/index.ts +151 -230
  127. package/src/pages/api/auth/[id].ts +48 -69
  128. package/src/pages/api/auth/audit-logs.ts +20 -43
  129. package/src/pages/api/auth/login.ts +159 -45
  130. package/src/pages/api/auth/logout.ts +42 -24
  131. package/src/pages/api/auth/refresh.ts +119 -0
  132. package/src/pages/api/auth/register.ts +110 -40
  133. package/src/pages/api/auth/users.ts +22 -97
  134. package/src/pages/api/collections.ts +59 -0
  135. package/src/pages/api/globals/[slug]/test.ts +172 -0
  136. package/src/pages/api/globals/[slug].ts +42 -0
  137. package/src/pages/api/graphql.ts +90 -0
  138. package/src/pages/api/health.ts +417 -40
  139. package/src/pages/api/keys/[id].ts +26 -0
  140. package/src/pages/api/keys/index.ts +75 -0
  141. package/src/pages/api/media/[id].ts +309 -0
  142. package/src/pages/api/media/folders.ts +609 -0
  143. package/src/pages/api/media/index.ts +146 -0
  144. package/src/pages/api/media/resize.ts +267 -0
  145. package/src/pages/api/search.ts +82 -0
  146. package/src/pages/api/slug-availability.ts +70 -0
  147. package/src/pages/api/storage-config.ts +20 -0
  148. package/src/pages/api/storage-status.ts +206 -0
  149. package/src/pages/api/upload.ts +334 -0
  150. package/src/pages/api/webhooks/index.ts +71 -0
  151. package/src/pages/audit/index.astro +2 -104
  152. package/src/pages/login.astro +11 -11
  153. package/src/pages/media.astro +10 -0
  154. package/src/pages/preview/[collection]/[id].astro +178 -0
  155. package/src/pages/register.astro +13 -13
  156. package/src/pages/roles/index.astro +21 -21
  157. package/src/pages/settings/[slug].astro +162 -0
  158. package/src/pages/settings/index.astro +9 -0
  159. package/src/pages/users/[id].astro +29 -21
  160. package/src/pages/users/index.astro +22 -17
  161. package/src/pages/users/new.astro +18 -17
  162. package/src/styles/main.css +553 -128
  163. package/src/components/layout/Sidebar.tsx +0 -497
@@ -1,4 +1,5 @@
1
- import type { NumberField as NumberFieldType } from '@kyro-cms/core';
1
+ import { useEffect, useState } from "react";
2
+ import type { NumberField as NumberFieldType } from "@kyro-cms/core";
2
3
 
3
4
  interface NumberFieldComponentProps {
4
5
  field: NumberFieldType;
@@ -8,37 +9,66 @@ interface NumberFieldComponentProps {
8
9
  disabled?: boolean;
9
10
  }
10
11
 
11
- export default function NumberField({ field, value, onChange, error, disabled }: NumberFieldComponentProps) {
12
+ export default function NumberField({
13
+ field,
14
+ value,
15
+ onChange,
16
+ error,
17
+ disabled,
18
+ }: NumberFieldComponentProps) {
19
+ const [isDark, setIsDark] = useState(false);
20
+
21
+ useEffect(() => {
22
+ setIsDark(document.documentElement.classList.contains("dark"));
23
+
24
+ const observer = new MutationObserver(() => {
25
+ setIsDark(document.documentElement.classList.contains("dark"));
26
+ });
27
+
28
+ observer.observe(document.documentElement, {
29
+ attributes: true,
30
+ attributeFilter: ["class"],
31
+ });
32
+
33
+ return () => observer.disconnect();
34
+ }, []);
35
+
12
36
  return (
13
37
  <div className="space-y-1">
14
38
  {field.label && (
15
- <label className="block text-sm font-medium text-gray-700">
39
+ <label className="block text-sm font-medium">
16
40
  {field.label}
17
41
  {field.required && <span className="text-red-500 ml-1">*</span>}
18
42
  </label>
19
43
  )}
20
44
  <input
21
45
  type="number"
22
- value={value ?? ''}
46
+ value={value ?? ""}
23
47
  onChange={(e) => onChange?.(parseFloat(e.target.value) || 0)}
24
48
  placeholder={field.admin?.placeholder}
25
49
  disabled={disabled || field.admin?.readOnly}
26
50
  min={field.min}
27
51
  max={field.max}
28
- step={field.step || (field.integer ? 1 : 'any')}
52
+ step={field.step || field.integer ? 1 : "any"}
29
53
  required={field.required}
30
54
  className={`w-full px-3 py-2 border rounded-md text-sm transition-colors ${
31
55
  error
32
- ? 'border-red-300 focus:border-red-500 focus:ring-red-500'
33
- : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
34
- } ${disabled || field.admin?.readOnly ? 'bg-gray-50 text-gray-500' : 'bg-white'}`}
56
+ ? "border-red-300 focus:border-red-500 focus:ring-red-500"
57
+ : "border-[var(--kyro-border)] focus:border-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)]"
58
+ } ${
59
+ disabled || field.admin?.readOnly
60
+ ? "bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-secondary)] opacity-50"
61
+ : isDark
62
+ ? "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"
63
+ : "bg-white text-gray-900"
64
+ }`}
35
65
  />
36
66
  {field.admin?.description && !error && (
37
- <p className="text-xs text-gray-500">{field.admin.description}</p>
38
- )}
39
- {error && (
40
- <p className="text-xs text-red-600">{error}</p>
67
+ <p className="text-xs text-[var(--kyro-text-secondary)]">
68
+ {field.admin.description}
69
+ </p>
41
70
  )}
71
+ {error && <p className="text-xs text-red-600">{error}</p>}
42
72
  </div>
43
73
  );
44
74
  }
@@ -0,0 +1,143 @@
1
+ import React, {
2
+ useState,
3
+ useEffect,
4
+ useMemo,
5
+ useCallback,
6
+ lazy,
7
+ Suspense,
8
+ } from "react";
9
+ import { useTheme } from "../ThemeProvider";
10
+
11
+ interface PortableTextFieldProps {
12
+ field: {
13
+ name: string;
14
+ label?: string;
15
+ required?: boolean;
16
+ admin?: {
17
+ description?: string;
18
+ placeholder?: string;
19
+ };
20
+ };
21
+ value?: any;
22
+ onChange?: (value: any) => void;
23
+ error?: string;
24
+ disabled?: boolean;
25
+ }
26
+
27
+ // Lazy load the editor to avoid SSR issues - only loads on client
28
+ const EditorLazy = lazy(() => import("./EditorClient"));
29
+
30
+ const PortableTextField: React.FC<PortableTextFieldProps> = ({
31
+ field,
32
+ value,
33
+ onChange,
34
+ error,
35
+ disabled,
36
+ }) => {
37
+ const { theme } = useTheme();
38
+ const [isMounted, setIsMounted] = useState(false);
39
+
40
+ const bgColor = theme.colors?.background || "#ffffff";
41
+ const borderColor = theme.colors?.border || "#e5e7eb";
42
+ const textColor = theme.colors?.text || "#374151";
43
+ const textMuted = theme.colors?.textMuted || "#9ca3af";
44
+
45
+ useEffect(() => {
46
+ setIsMounted(true);
47
+ }, []);
48
+
49
+ // Parse value to Portable Text JSON format
50
+ const initialValue = useMemo(() => {
51
+ if (!value) return [];
52
+ if (Array.isArray(value)) {
53
+ // Validate it's proper portable text blocks
54
+ if (
55
+ value.length > 0 &&
56
+ value[0] &&
57
+ typeof value[0] === "object" &&
58
+ "_type" in value[0]
59
+ ) {
60
+ return value;
61
+ }
62
+ }
63
+ // If string or invalid, return empty
64
+ return [];
65
+ }, [value]);
66
+
67
+ const handleChange = useCallback(
68
+ (blocks: any[]) => {
69
+ if (!blocks || !Array.isArray(blocks)) {
70
+ onChange?.([]);
71
+ return;
72
+ }
73
+ onChange?.(blocks);
74
+ },
75
+ [onChange],
76
+ );
77
+
78
+ if (!isMounted) {
79
+ return (
80
+ <div className="space-y-1.5">
81
+ {field.label && (
82
+ <label
83
+ className="block text-sm font-medium"
84
+ style={{ color: textColor }}
85
+ >
86
+ {field.label}
87
+ {field.required && <span className="text-red-500 ml-1">*</span>}
88
+ </label>
89
+ )}
90
+ <div
91
+ className="h-[200px] rounded-lg border animate-pulse"
92
+ style={{ backgroundColor: bgColor, borderColor }}
93
+ />
94
+ </div>
95
+ );
96
+ }
97
+
98
+ return (
99
+ <div className="space-y-1.5">
100
+ {field.label && (
101
+ <label
102
+ className="block text-sm font-medium"
103
+ style={{ color: textColor }}
104
+ >
105
+ {field.label}
106
+ {field.required && <span className="text-red-500 ml-1">*</span>}
107
+ </label>
108
+ )}
109
+ <div
110
+ className={`border rounded-lg overflow-hidden ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
111
+ style={{ borderColor }}
112
+ >
113
+ <Suspense
114
+ fallback={
115
+ <div
116
+ className="h-[200px] flex items-center justify-center"
117
+ style={{ backgroundColor: bgColor }}
118
+ >
119
+ <span className="text-sm" style={{ color: textMuted }}>
120
+ Loading editor...
121
+ </span>
122
+ </div>
123
+ }
124
+ >
125
+ <EditorLazy
126
+ initialValue={initialValue}
127
+ onChange={handleChange}
128
+ disabled={disabled}
129
+ theme={theme}
130
+ />
131
+ </Suspense>
132
+ </div>
133
+ {field.admin?.description && !error && (
134
+ <p className="text-xs" style={{ color: textMuted }}>
135
+ {field.admin.description}
136
+ </p>
137
+ )}
138
+ {error && <p className="text-xs text-red-500">{error}</p>}
139
+ </div>
140
+ );
141
+ };
142
+
143
+ export default PortableTextField;
@@ -0,0 +1,68 @@
1
+ import React from "react";
2
+ import { PortableText } from "@portabletext/react";
3
+ import type { PortableTextComponents } from "@portabletext/react";
4
+
5
+ interface PortableTextRendererProps {
6
+ value: any[];
7
+ className?: string;
8
+ }
9
+
10
+ const components: PortableTextComponents = {
11
+ block: {
12
+ h1: ({ children }) => (
13
+ <h1 className="text-2xl font-bold mb-2">{children}</h1>
14
+ ),
15
+ h2: ({ children }) => (
16
+ <h2 className="text-xl font-bold mb-2">{children}</h2>
17
+ ),
18
+ h3: ({ children }) => (
19
+ <h3 className="text-lg font-semibold mb-2">{children}</h3>
20
+ ),
21
+ blockquote: ({ children }) => (
22
+ <blockquote className="border-l-4 border-gray-300 pl-4 italic my-2">
23
+ {children}
24
+ </blockquote>
25
+ ),
26
+ normal: ({ children }) => <p className="my-1">{children}</p>,
27
+ },
28
+ marks: {
29
+ strong: ({ children }) => <strong>{children}</strong>,
30
+ em: ({ children }) => <em>{children}</em>,
31
+ code: ({ children }) => (
32
+ <code className="bg-gray-100 px-1 rounded text-sm font-mono">
33
+ {children}
34
+ </code>
35
+ ),
36
+ underline: ({ children }) => <u>{children}</u>,
37
+ strikeThrough: ({ children }) => <s>{children}</s>,
38
+ },
39
+ list: {
40
+ bullet: ({ children }) => (
41
+ <ul className="list-disc ml-4 my-2">{children}</ul>
42
+ ),
43
+ number: ({ children }) => (
44
+ <ol className="list-decimal ml-4 my-2">{children}</ol>
45
+ ),
46
+ },
47
+ listItem: {
48
+ bullet: ({ children }) => <li className="my-1">{children}</li>,
49
+ number: ({ children }) => <li className="my-1">{children}</li>,
50
+ },
51
+ };
52
+
53
+ export const PortableTextRenderer: React.FC<PortableTextRendererProps> = ({
54
+ value,
55
+ className = "",
56
+ }) => {
57
+ if (!value || !Array.isArray(value) || value.length === 0) {
58
+ return null;
59
+ }
60
+
61
+ return (
62
+ <div className={className}>
63
+ <PortableText value={value} components={components} />
64
+ </div>
65
+ );
66
+ };
67
+
68
+ export default PortableTextRenderer;
@@ -1,87 +1,259 @@
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 url = query
45
+ ? `/api/${targetCollection}?where[title][contains]=${encodeURIComponent(query)}&limit=20`
46
+ : `/api/${targetCollection}?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((err) => {
55
+ console.error("Failed to fetch relations:", err);
56
+ setLoading(false);
57
+ });
58
+ };
59
+
60
+ useEffect(() => {
61
+ if (isOpen) {
62
+ fetchOptions(search);
63
+ }
64
+ }, [isOpen, targetCollection]);
65
+
66
+ useEffect(() => {
67
+ const handleClickOutside = (event: MouseEvent) => {
68
+ if (
69
+ containerRef.current &&
70
+ !containerRef.current.contains(event.target as Node)
71
+ ) {
72
+ setIsOpen(false);
73
+ }
74
+ };
75
+ document.addEventListener("mousedown", handleClickOutside);
76
+ return () => document.removeEventListener("mousedown", handleClickOutside);
77
+ }, []);
78
+
79
+ const getLabel = (opt: any) => {
80
+ return (
81
+ opt?.title ||
82
+ opt?.name ||
83
+ opt?.label ||
84
+ opt?.filename ||
85
+ opt?.slug ||
86
+ opt?.id ||
87
+ "Untitled"
88
+ );
89
+ };
90
+
91
+ const getValueId = (val: any) => {
92
+ return val?.id || val;
93
+ };
94
+
95
+ const isSelected = (opt: any) => {
96
+ const optId = opt.id;
97
+ if (!value) return false;
98
+ if (isMultiple && Array.isArray(value)) {
99
+ return value.some((v) => getValueId(v) === optId);
100
+ }
101
+ return getValueId(value) === optId;
102
+ };
103
+
104
+ const handleSelect = (opt: any) => {
105
+ const optId = opt.id;
106
+ if (isMultiple) {
107
+ const current = Array.isArray(value) ? value : [];
108
+ if (isSelected(opt)) {
109
+ onChange?.(current.filter((v) => getValueId(v) !== optId));
110
+ } else {
111
+ onChange?.([...current, optId]);
112
+ }
113
+ } else {
114
+ if (isSelected(opt)) {
115
+ onChange?.(null);
116
+ } else {
117
+ onChange?.(optId);
118
+ setIsOpen(false);
119
+ setSearch("");
120
+ }
121
+ }
122
+ };
123
+
124
+ const handleClear = () => {
125
+ onChange?.(isMultiple ? [] : null);
126
+ };
127
+
128
+ const renderSelectedItems = () => {
129
+ if (!value) return null;
130
+
131
+ const items = isMultiple ? (value as string[]) : [value];
132
+
133
+ return (
134
+ <div className="flex flex-wrap gap-1.5 mt-2">
135
+ {items.map((itemId, idx) => {
136
+ const opt = options.find((o) => o.id === itemId) || { id: itemId };
137
+ return (
138
+ <span
139
+ key={idx}
140
+ 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)]"
141
+ >
142
+ {getLabel(opt)}
143
+ {!disabled && (
144
+ <button
145
+ type="button"
146
+ onClick={() => {
147
+ if (isMultiple) {
148
+ onChange?.(
149
+ (value as string[]).filter((_, i) => i !== idx),
150
+ );
151
+ } else {
152
+ onChange?.(null);
153
+ }
154
+ }}
155
+ className="hover:opacity-70"
156
+ >
157
+ <X className="w-3 h-3" />
158
+ </button>
159
+ )}
160
+ </span>
161
+ );
162
+ })}
163
+ </div>
164
+ );
165
+ };
17
166
 
18
167
  return (
19
- <div className="space-y-1">
168
+ <div className="space-y-1.5">
20
169
  {field.label && (
21
- <label className="block text-sm font-medium text-gray-700">
170
+ <label className="block text-sm font-medium text-[var(--kyro-text-primary)]">
22
171
  {field.label}
23
172
  {field.required && <span className="text-red-500 ml-1">*</span>}
24
173
  </label>
25
174
  )}
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 */}
175
+ <div ref={containerRef} className="relative">
176
+ <div className="relative">
177
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
178
+ <input
179
+ type="text"
180
+ value={search}
181
+ onChange={(e) => {
182
+ setSearch(e.target.value);
183
+ setIsOpen(true);
184
+ fetchOptions(e.target.value);
185
+ }}
186
+ onFocus={() => setIsOpen(true)}
187
+ placeholder={
188
+ field.admin?.placeholder || `Search ${targetCollection}...`
189
+ }
190
+ disabled={disabled || field.admin?.readOnly}
191
+ 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"
192
+ />
193
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
194
+ {loading ? (
195
+ <Loader2 className="w-4 h-4 text-[var(--kyro-text-muted)] animate-spin" />
196
+ ) : (
197
+ <ChevronDown
198
+ className={`w-4 h-4 text-[var(--kyro-text-muted)] transition-transform ${isOpen ? "rotate-180" : ""}`}
199
+ />
200
+ )}
47
201
  </div>
48
- )}
202
+ </div>
49
203
 
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 && (
204
+ {isOpen && (
205
+ <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">
206
+ {loading ? (
207
+ <div className="p-4 text-center text-sm text-[var(--kyro-text-muted)]">
208
+ Loading...
209
+ </div>
210
+ ) : options.length === 0 ? (
211
+ <div className="p-4 text-center text-sm text-[var(--kyro-text-muted)]">
212
+ No results found
213
+ </div>
214
+ ) : (
215
+ <div className="py-1">
216
+ {options.map((opt) => (
60
217
  <button
218
+ key={opt.id}
61
219
  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"
220
+ onClick={() => handleSelect(opt)}
221
+ className={`w-full px-3 py-2 text-left text-sm hover:bg-[var(--kyro-surface-accent)] transition-colors ${
222
+ isSelected(opt)
223
+ ? "bg-[var(--kyro-sidebar-active)]/10 text-[var(--kyro-sidebar-active)]"
224
+ : "text-[var(--kyro-text-primary)]"
225
+ }`}
70
226
  >
71
- ×
227
+ <div className="flex items-center justify-between">
228
+ <span>{getLabel(opt)}</span>
229
+ {isSelected(opt) && (
230
+ <span className="text-[var(--kyro-sidebar-active)]">
231
+
232
+ </span>
233
+ )}
234
+ </div>
235
+ {opt.slug && (
236
+ <div className="text-xs text-[var(--kyro-text-muted)]">
237
+ {opt.slug}
238
+ </div>
239
+ )}
72
240
  </button>
73
- )}
74
- </span>
75
- ))}
241
+ ))}
242
+ </div>
243
+ )}
76
244
  </div>
77
245
  )}
246
+
247
+ {renderSelectedItems()}
78
248
  </div>
79
249
  {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>
250
+ <p className="text-xs text-[var(--kyro-text-muted)]">
251
+ {field.admin.description}
252
+ </p>
84
253
  )}
254
+ {error && <p className="text-xs text-red-500">{error}</p>}
85
255
  </div>
86
256
  );
87
257
  }
258
+
259
+ export default RelationshipField;