@kyro-cms/admin 0.1.7 → 0.1.9

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 (71) hide show
  1. package/package.json +7 -2
  2. package/src/components/Admin.tsx +1 -1
  3. package/src/components/AutoForm.tsx +966 -337
  4. package/src/components/CreateView.tsx +1 -1
  5. package/src/components/DetailView.tsx +1 -1
  6. package/src/components/EnhancedListView.tsx +156 -52
  7. package/src/components/ListView.tsx +1 -1
  8. package/src/components/Modal.tsx +65 -8
  9. package/src/components/Sidebar.astro +2 -2
  10. package/src/components/ThemeProvider.tsx +8 -2
  11. package/src/components/blocks/AccordionBlock.tsx +20 -52
  12. package/src/components/blocks/ArrayBlock.tsx +40 -31
  13. package/src/components/blocks/BlockEditModal.tsx +170 -581
  14. package/src/components/blocks/ButtonBlock.tsx +27 -128
  15. package/src/components/blocks/CodeBlock.tsx +88 -40
  16. package/src/components/blocks/ColumnsBlock.tsx +27 -85
  17. package/src/components/blocks/FileBlock.tsx +38 -39
  18. package/src/components/blocks/HeadingBlock.tsx +9 -31
  19. package/src/components/blocks/HeroBlock.tsx +42 -100
  20. package/src/components/blocks/ImageBlock.tsx +6 -7
  21. package/src/components/blocks/LinkBlock.tsx +27 -33
  22. package/src/components/blocks/ListBlock.tsx +47 -26
  23. package/src/components/blocks/RelationshipBlock.tsx +26 -233
  24. package/src/components/blocks/RichTextBlock.tsx +66 -0
  25. package/src/components/blocks/VStackBlock.tsx +23 -37
  26. package/src/components/blocks/VideoBlock.tsx +52 -32
  27. package/src/components/fields/AccordionField.tsx +213 -0
  28. package/src/components/fields/ArrayField.tsx +241 -0
  29. package/src/components/fields/BlocksField.tsx +5 -5
  30. package/src/components/fields/ButtonField.tsx +53 -0
  31. package/src/components/fields/CheckboxField.tsx +7 -3
  32. package/src/components/fields/ChildrenField.tsx +48 -0
  33. package/src/components/fields/CodeField.tsx +154 -94
  34. package/src/components/fields/ColumnsField.tsx +137 -0
  35. package/src/components/fields/DateField.tsx +9 -24
  36. package/src/components/fields/EditorClient.tsx +426 -160
  37. package/src/components/fields/HeadingField.tsx +31 -0
  38. package/src/components/fields/HeroField.tsx +101 -0
  39. package/src/components/fields/JSONField.tsx +7 -27
  40. package/src/components/fields/LinkField.tsx +81 -0
  41. package/src/components/fields/ListField.tsx +74 -0
  42. package/src/components/fields/MarkdownField.tsx +4 -26
  43. package/src/components/fields/NumberField.tsx +9 -27
  44. package/src/components/fields/PortableTextField.tsx +61 -49
  45. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  46. package/src/components/fields/RelationshipField.tsx +59 -13
  47. package/src/components/fields/SelectField.tsx +6 -4
  48. package/src/components/fields/TextField.tsx +9 -24
  49. package/src/components/fields/UploadField.tsx +613 -0
  50. package/src/components/fields/VideoField.tsx +73 -0
  51. package/src/components/fields/extensions/blockComponents.tsx +11 -1
  52. package/src/components/fields/extensions/blocksStore.ts +1 -1
  53. package/src/components/fields/index.ts +12 -1
  54. package/src/components/layout/Layout.tsx +1 -1
  55. package/src/lib/api.ts +163 -0
  56. package/src/lib/config.ts +1 -1
  57. package/src/lib/dataStore.ts +87 -30
  58. package/src/lib/date-utils.ts +69 -0
  59. package/src/lib/db/version-adapter.ts +248 -0
  60. package/src/lib/i18n.tsx +353 -0
  61. package/src/lib/slugify.ts +15 -0
  62. package/src/lib/validation.ts +250 -0
  63. package/src/pages/api/[collection]/[id]/publish.ts +12 -4
  64. package/src/pages/api/[collection]/[id]/versions.ts +39 -9
  65. package/src/pages/api/[collection]/[id].ts +13 -1
  66. package/src/pages/api/[collection]/index.ts +5 -6
  67. package/src/styles/main.css +12 -2
  68. package/src/components/blocks/BlockEditModal.MARKER +0 -12
  69. package/src/components/fields/FileField.tsx +0 -390
  70. package/src/components/fields/HybridContentField.tsx +0 -109
  71. package/src/components/fields/ImageField.tsx +0 -429
@@ -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;
@@ -41,22 +41,58 @@ export function RelationshipField({
41
41
 
42
42
  const fetchOptions = (query: string = "") => {
43
43
  setLoading(true);
44
+ const searchFields = ["title", "name", "label", "email"];
45
+ const searchQuery = searchFields
46
+ .map((f) => `where[${f}][contains]=${encodeURIComponent(query)}`)
47
+ .join("&");
44
48
  const url = query
45
- ? `/api/${targetCollection}?where[title][contains]=${encodeURIComponent(query)}&limit=20`
46
- : `/api/${targetCollection}?limit=20`;
49
+ ? `/api/${targetCollection}?${searchQuery}&limit=50`
50
+ : `/api/${targetCollection}?limit=50`;
47
51
 
48
52
  fetch(url, { credentials: "include" })
49
53
  .then((res) => res.json())
50
54
  .then((data) => {
51
- setOptions(data.docs || []);
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
+ });
52
62
  setLoading(false);
53
63
  })
54
- .catch((err) => {
55
- console.error("Failed to fetch relations:", err);
64
+ .catch(() => {
56
65
  setLoading(false);
57
66
  });
58
67
  };
59
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
+
60
96
  useEffect(() => {
61
97
  if (isOpen) {
62
98
  fetchOptions(search);
@@ -81,9 +117,10 @@ export function RelationshipField({
81
117
  opt?.title ||
82
118
  opt?.name ||
83
119
  opt?.label ||
120
+ opt?.email ||
84
121
  opt?.filename ||
85
122
  opt?.slug ||
86
- opt?.id ||
123
+ String(opt?.id) ||
87
124
  "Untitled"
88
125
  );
89
126
  };
@@ -128,25 +165,32 @@ export function RelationshipField({
128
165
  const renderSelectedItems = () => {
129
166
  if (!value) return null;
130
167
 
131
- const items = isMultiple ? (value as string[]) : [value];
168
+ let items: any[];
169
+ if (isMultiple) {
170
+ items = Array.isArray(value) ? value : [];
171
+ } else {
172
+ items = value ? [value] : [];
173
+ }
132
174
 
133
175
  return (
134
176
  <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 };
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);
137
181
  return (
138
182
  <span
139
183
  key={idx}
140
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)]"
141
185
  >
142
- {getLabel(opt)}
186
+ {label}
143
187
  {!disabled && (
144
188
  <button
145
189
  type="button"
146
190
  onClick={() => {
147
191
  if (isMultiple) {
148
192
  onChange?.(
149
- (value as string[]).filter((_, i) => i !== idx),
193
+ items.filter((_: any, i: number) => i !== idx),
150
194
  );
151
195
  } else {
152
196
  onChange?.(null);
@@ -169,7 +213,9 @@ export function RelationshipField({
169
213
  {field.label && (
170
214
  <label className="block text-sm font-medium text-[var(--kyro-text-primary)]">
171
215
  {field.label}
172
- {field.required && <span className="text-red-500 ml-1">*</span>}
216
+ {field.required && (
217
+ <span className="text-[var(--kyro-error)] ml-1">*</span>
218
+ )}
173
219
  </label>
174
220
  )}
175
221
  <div ref={containerRef} className="relative">
@@ -251,7 +297,7 @@ export function RelationshipField({
251
297
  {field.admin.description}
252
298
  </p>
253
299
  )}
254
- {error && <p className="text-xs text-red-500">{error}</p>}
300
+ {error && <p className="text-xs text-[var(--kyro-error)]">{error}</p>}
255
301
  </div>
256
302
  );
257
303
  }
@@ -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;
@@ -20,7 +20,9 @@ export default function SelectField({
20
20
  {field.label && (
21
21
  <label className="block text-sm font-medium text-[var(--kyro-text-primary)]">
22
22
  {field.label}
23
- {field.required && <span className="text-red-500 ml-1">*</span>}
23
+ {field.required && (
24
+ <span className="text-[var(--kyro-error)] ml-1">*</span>
25
+ )}
24
26
  </label>
25
27
  )}
26
28
  <select
@@ -44,7 +46,7 @@ export default function SelectField({
44
46
  required={field.required}
45
47
  className={`w-full px-3 py-2 border rounded-md text-sm transition-colors ${
46
48
  error
47
- ? "border-red-500 focus:border-red-500 focus:ring-red-500"
49
+ ? "border-[var(--kyro-error)] focus:border-[var(--kyro-error)] focus:ring-[var(--kyro-error)]"
48
50
  : "border-[var(--kyro-border)] focus:border-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)]"
49
51
  } ${disabled || field.admin?.readOnly ? "bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-muted)]" : "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"}`}
50
52
  >
@@ -60,7 +62,7 @@ export default function SelectField({
60
62
  {field.admin.description}
61
63
  </p>
62
64
  )}
63
- {error && <p className="text-xs text-red-500">{error}</p>}
65
+ {error && <p className="text-xs text-[var(--kyro-error)]">{error}</p>}
64
66
  </div>
65
67
  );
66
68
  }
@@ -1,5 +1,4 @@
1
- import { useEffect, useState } from "react";
2
- import type { TextField as TextFieldType } from "@kyro-cms/core";
1
+ import type { TextField as TextFieldType } from "@kyro-cms/core/client";
3
2
 
4
3
  interface TextFieldComponentProps {
5
4
  field: TextFieldType;
@@ -16,20 +15,6 @@ export default function TextField({
16
15
  error,
17
16
  disabled,
18
17
  }: TextFieldComponentProps) {
19
- const [isDark, setIsDark] = useState(false);
20
-
21
- useEffect(() => {
22
- setIsDark(document.documentElement.classList.contains("dark"));
23
- const observer = new MutationObserver(() => {
24
- setIsDark(document.documentElement.classList.contains("dark"));
25
- });
26
- observer.observe(document.documentElement, {
27
- attributes: true,
28
- attributeFilter: ["class"],
29
- });
30
- return () => observer.disconnect();
31
- }, []);
32
-
33
18
  const inputType =
34
19
  field.variant === "email"
35
20
  ? "email"
@@ -42,9 +27,11 @@ export default function TextField({
42
27
  return (
43
28
  <div className="space-y-1">
44
29
  {field.label && (
45
- <label className="block text-sm font-medium">
30
+ <label className="block text-sm font-medium text-[var(--kyro-text-primary)]">
46
31
  {field.label}
47
- {field.required && <span className="text-red-500 ml-1">*</span>}
32
+ {field.required && (
33
+ <span className="text-[var(--kyro-error)] ml-1">*</span>
34
+ )}
48
35
  </label>
49
36
  )}
50
37
  <input
@@ -59,22 +46,20 @@ export default function TextField({
59
46
  required={field.required}
60
47
  className={`w-full px-3 py-2 border rounded-md text-sm transition-colors ${
61
48
  error
62
- ? "border-red-300 focus:border-red-500 focus:ring-red-500"
49
+ ? "border-[var(--kyro-error)] focus:border-[var(--kyro-error)] focus:ring-[var(--kyro-error)]"
63
50
  : "border-[var(--kyro-border)] focus:border-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)]"
64
51
  } ${
65
52
  disabled || field.admin?.readOnly
66
53
  ? "bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-secondary)] opacity-50"
67
- : isDark
68
- ? "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"
69
- : "bg-white text-gray-900"
54
+ : "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"
70
55
  }`}
71
56
  />
72
57
  {field.admin?.description && !error && (
73
- <p className="text-xs text-[var(--kyro-text-secondary)]">
58
+ <p className="text-xs text-[var(--kyro-text-muted)]">
74
59
  {field.admin.description}
75
60
  </p>
76
61
  )}
77
- {error && <p className="text-xs text-red-600">{error}</p>}
62
+ {error && <p className="text-xs text-[var(--kyro-error)]">{error}</p>}
78
63
  </div>
79
64
  );
80
65
  }