@kyro-cms/admin 0.1.5 → 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 (164) hide show
  1. package/README.md +149 -51
  2. package/package.json +52 -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 +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 +50 -0
  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 +116 -28
  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 +286 -0
  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 +50 -20
  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 +82 -0
  153. package/src/pages/media.astro +10 -0
  154. package/src/pages/preview/[collection]/[id].astro +178 -0
  155. package/src/pages/register.astro +102 -0
  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
  164. package/src/pages/index.astro +0 -225
@@ -0,0 +1,271 @@
1
+ import React, { useState, useCallback } from "react";
2
+ import {
3
+ EditorProvider,
4
+ PortableTextEditable,
5
+ useEditor,
6
+ } from "@portabletext/editor";
7
+ import { defineSchema } from "@portabletext/schema";
8
+ import { EventListenerPlugin } from "@portabletext/editor/plugins";
9
+
10
+ interface EditorClientProps {
11
+ initialValue: any[];
12
+ onChange: (blocks: any[]) => void;
13
+ disabled?: boolean;
14
+ theme: any;
15
+ }
16
+
17
+ /**
18
+ * Sanitize any incoming value into a valid Portable Text block array.
19
+ * The @portabletext/editor will throw `charCodeAt` errors if it receives
20
+ * undefined, null, a plain string, or blocks with missing/non-string text.
21
+ */
22
+ function sanitizeInitialValue(value: any): any[] {
23
+ if (!value || !Array.isArray(value)) return [];
24
+ return value.filter((block) => {
25
+ if (!block || typeof block !== "object") return false;
26
+ if (!block._type) return false;
27
+ // For text blocks, ensure every span has a string `text` property
28
+ if (block._type === "block" && Array.isArray(block.children)) {
29
+ block.children = block.children.map((child: any) => ({
30
+ ...child,
31
+ _type: child._type || "span",
32
+ text: typeof child.text === "string" ? child.text : "",
33
+ }));
34
+ }
35
+ return true;
36
+ });
37
+ }
38
+
39
+ // Define basic portable text schema
40
+ const schemaDefinition = defineSchema({
41
+ decorators: [
42
+ { name: "strong", title: "Bold" },
43
+ { name: "em", title: "Italic" },
44
+ { name: "underline", title: "Underline" },
45
+ { name: "strikeThrough", title: "Strikethrough" },
46
+ { name: "code", title: "Code" },
47
+ ],
48
+ styles: [
49
+ { name: "normal", title: "Paragraph" },
50
+ { name: "h1", title: "Heading 1" },
51
+ { name: "h2", title: "Heading 2" },
52
+ { name: "h3", title: "Heading 3" },
53
+ { name: "blockquote", title: "Quote" },
54
+ ],
55
+ lists: [
56
+ { name: "bullet", title: "Bullet List" },
57
+ { name: "number", title: "Numbered List" },
58
+ ],
59
+ annotations: [],
60
+ inlineObjects: [],
61
+ blockObjects: [],
62
+ });
63
+
64
+ const Toolbar: React.FC<{ onInsertTag: (tag: string) => void }> = ({
65
+ onInsertTag,
66
+ }) => {
67
+ const textColor = "#374151";
68
+ const textMuted = "#9ca3af";
69
+
70
+ return (
71
+ <div
72
+ className="flex items-center gap-0.5 p-2 border-b flex-wrap"
73
+ style={{ backgroundColor: "#f9fafb", borderColor: "#e5e7eb" }}
74
+ >
75
+ <button
76
+ type="button"
77
+ onClick={() => onInsertTag("strong")}
78
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200 font-bold"
79
+ style={{ color: textColor }}
80
+ title="Bold"
81
+ >
82
+ B
83
+ </button>
84
+ <button
85
+ type="button"
86
+ onClick={() => onInsertTag("em")}
87
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200 italic"
88
+ style={{ color: textColor }}
89
+ title="Italic"
90
+ >
91
+ I
92
+ </button>
93
+ <button
94
+ type="button"
95
+ onClick={() => onInsertTag("underline")}
96
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200 underline"
97
+ style={{ color: textColor }}
98
+ title="Underline"
99
+ >
100
+ U
101
+ </button>
102
+ <button
103
+ type="button"
104
+ onClick={() => onInsertTag("strikeThrough")}
105
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200 line-through"
106
+ style={{ color: textColor }}
107
+ title="Strikethrough"
108
+ >
109
+ S
110
+ </button>
111
+ <button
112
+ type="button"
113
+ onClick={() => onInsertTag("code")}
114
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200 font-mono text-xs"
115
+ style={{ color: textColor }}
116
+ title="Code"
117
+ >
118
+ {"</>"}
119
+ </button>
120
+
121
+ <div
122
+ className="w-px h-6 mx-1"
123
+ style={{ backgroundColor: textMuted + "40" }}
124
+ />
125
+
126
+ <button
127
+ type="button"
128
+ onClick={() => onInsertTag("h1")}
129
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200 font-bold"
130
+ style={{ color: textColor }}
131
+ title="Heading 1"
132
+ >
133
+ H1
134
+ </button>
135
+ <button
136
+ type="button"
137
+ onClick={() => onInsertTag("h2")}
138
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200 font-bold"
139
+ style={{ color: textColor }}
140
+ title="Heading 2"
141
+ >
142
+ H2
143
+ </button>
144
+ <button
145
+ type="button"
146
+ onClick={() => onInsertTag("h3")}
147
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200 font-semibold"
148
+ style={{ color: textColor }}
149
+ title="Heading 3"
150
+ >
151
+ H3
152
+ </button>
153
+ <button
154
+ type="button"
155
+ onClick={() => onInsertTag("blockquote")}
156
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200"
157
+ style={{ color: textColor }}
158
+ title="Quote"
159
+ >
160
+ <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
161
+ <path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
162
+ </svg>
163
+ </button>
164
+
165
+ <div
166
+ className="w-px h-6 mx-1"
167
+ style={{ backgroundColor: textMuted + "40" }}
168
+ />
169
+
170
+ <button
171
+ type="button"
172
+ onClick={() => onInsertTag("bullet")}
173
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200"
174
+ style={{ color: textColor }}
175
+ title="Bullet List"
176
+ >
177
+ <svg
178
+ className="w-4 h-4"
179
+ viewBox="0 0 24 24"
180
+ fill="none"
181
+ stroke="currentColor"
182
+ strokeWidth="2"
183
+ >
184
+ <path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
185
+ </svg>
186
+ </button>
187
+ <button
188
+ type="button"
189
+ onClick={() => onInsertTag("number")}
190
+ className="px-2 py-1 text-sm rounded hover:bg-gray-200"
191
+ style={{ color: textColor }}
192
+ title="Numbered List"
193
+ >
194
+ <svg
195
+ className="w-4 h-4"
196
+ viewBox="0 0 24 24"
197
+ fill="none"
198
+ stroke="currentColor"
199
+ strokeWidth="2"
200
+ >
201
+ <path d="M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
202
+ </svg>
203
+ </button>
204
+ </div>
205
+ );
206
+ };
207
+
208
+ const EditorInner: React.FC<{
209
+ onChange: (blocks: any[]) => void;
210
+ disabled?: boolean;
211
+ }> = ({ onChange, disabled }) => {
212
+ const editor = useEditor();
213
+
214
+ const handleInsertTag = useCallback(
215
+ (tag: string) => {
216
+ if (!editor) return;
217
+ console.log("Insert tag:", tag, "Editor available:", !!editor);
218
+ },
219
+ [editor],
220
+ );
221
+
222
+ return (
223
+ <>
224
+ <Toolbar onInsertTag={handleInsertTag} />
225
+ <PortableTextEditable
226
+ className="min-h-[200px] p-4 focus:outline-none"
227
+ style={{ backgroundColor: "white", color: "#333" }}
228
+ placeholder="Start typing..."
229
+ readOnly={disabled}
230
+ />
231
+ </>
232
+ );
233
+ };
234
+
235
+ export const EditorClient: React.FC<EditorClientProps> = ({
236
+ initialValue,
237
+ onChange,
238
+ disabled,
239
+ theme,
240
+ }) => {
241
+ // Sanitize on mount — never pass raw/malformed data to EditorProvider
242
+ const [value, setValue] = useState(() => sanitizeInitialValue(initialValue));
243
+
244
+ const handleChange = useCallback(
245
+ (newValue: any) => {
246
+ setValue(newValue);
247
+ onChange(newValue);
248
+ },
249
+ [onChange],
250
+ );
251
+
252
+ return (
253
+ <EditorProvider
254
+ initialConfig={{
255
+ schemaDefinition: schemaDefinition as any,
256
+ initialValue: value,
257
+ }}
258
+ >
259
+ <EventListenerPlugin
260
+ on={(event: any) => {
261
+ if (event.type === "mutation" && event.value) {
262
+ handleChange(event.value);
263
+ }
264
+ }}
265
+ />
266
+ <EditorInner onChange={handleChange} disabled={disabled} />
267
+ </EditorProvider>
268
+ );
269
+ };
270
+
271
+ export default EditorClient;
@@ -0,0 +1,390 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+
3
+ interface FileFieldProps {
4
+ field: any;
5
+ value: any;
6
+ onChange: (value: any) => void;
7
+ disabled?: boolean;
8
+ }
9
+
10
+ interface MediaItem {
11
+ id: string;
12
+ filename: string;
13
+ url: string;
14
+ mimeType: string;
15
+ size?: number;
16
+ title?: string;
17
+ folder?: string;
18
+ }
19
+
20
+ interface MediaFolder {
21
+ name: string;
22
+ path: string;
23
+ }
24
+
25
+ export function FileField({
26
+ field,
27
+ value,
28
+ onChange,
29
+ disabled,
30
+ }: FileFieldProps) {
31
+ const inputRef = useRef<HTMLInputElement>(null);
32
+ const urlInputRef = useRef<HTMLInputElement>(null);
33
+ const [uploading, setUploading] = useState(false);
34
+ const [showPicker, setShowPicker] = useState(false);
35
+ const [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
36
+ const [folders, setFolders] = useState<MediaFolder[]>([]);
37
+ const [selectedFolder, setSelectedFolder] = useState<string>("");
38
+ const [mediaLoading, setMediaLoading] = useState(false);
39
+ const [showUrlInput, setShowUrlInput] = useState(false);
40
+ const [urlValue, setUrlValue] = useState("");
41
+ const [urlError, setUrlError] = useState("");
42
+
43
+ const fieldLabel = field?.label || field?.name || "File";
44
+ const maxCount = field.maxCount || 1;
45
+ const isMultiple = maxCount > 1;
46
+ const currentValue = Array.isArray(value) ? value : value ? [value] : [];
47
+ const canAddMore = currentValue.length < maxCount;
48
+
49
+ useEffect(() => {
50
+ if (showPicker) {
51
+ loadFolders();
52
+ loadMedia();
53
+ }
54
+ }, [showPicker, selectedFolder]);
55
+
56
+ const loadFolders = async () => {
57
+ try {
58
+ const resp = await fetch("/api/media/folders?t=" + Date.now(), {
59
+ credentials: "include",
60
+ });
61
+ const result = await resp.json();
62
+ setFolders(result.folders || []);
63
+ } catch {
64
+ setFolders([]);
65
+ }
66
+ };
67
+
68
+ const loadMedia = async () => {
69
+ setMediaLoading(true);
70
+ try {
71
+ let url = `/api/media?limit=60&sortBy=createdAt&sortDir=desc&t=${Date.now()}&mimeType=application,video,audio`;
72
+ if (selectedFolder) {
73
+ url += "&folder=" + encodeURIComponent(selectedFolder);
74
+ }
75
+ const resp = await fetch(url, { credentials: "include" });
76
+ const result = await resp.json();
77
+ setMediaItems(result.docs || []);
78
+ } catch {
79
+ setMediaItems([]);
80
+ } finally {
81
+ setMediaLoading(false);
82
+ }
83
+ };
84
+
85
+ const uploadFile = async (file: File) => {
86
+ setUploading(true);
87
+ try {
88
+ const formData = new FormData();
89
+ formData.append("file", file);
90
+ if (selectedFolder) {
91
+ formData.append("folder", selectedFolder);
92
+ }
93
+ const resp = await fetch("/api/upload", {
94
+ method: "POST",
95
+ body: formData,
96
+ credentials: "include",
97
+ });
98
+ if (!resp.ok) throw new Error("Upload failed");
99
+ const result = await resp.json();
100
+ const newItem = {
101
+ id: result.id,
102
+ filename: result.filename,
103
+ url: result.url,
104
+ mimeType: result.mimeType,
105
+ size: result.size,
106
+ };
107
+ if (isMultiple) {
108
+ onChange([...currentValue, newItem]);
109
+ } else {
110
+ onChange(newItem);
111
+ }
112
+ } catch (err) {
113
+ console.error("Upload failed:", err);
114
+ } finally {
115
+ setUploading(false);
116
+ }
117
+ };
118
+
119
+ const handleDrop = (e: React.DragEvent) => {
120
+ e.preventDefault();
121
+ const files = Array.from(e.dataTransfer.files);
122
+ if (files.length > 0) {
123
+ uploadFile(files[0]);
124
+ }
125
+ };
126
+
127
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
128
+ const files = e.target.files;
129
+ if (files && files.length > 0) {
130
+ uploadFile(files[0]);
131
+ }
132
+ };
133
+
134
+ const addByUrl = async () => {
135
+ if (!urlValue.trim()) return;
136
+ setUrlError("");
137
+ try {
138
+ new URL(urlValue);
139
+ } catch {
140
+ setUrlError("Invalid URL");
141
+ return;
142
+ }
143
+ const newItem = {
144
+ id: `url-${Date.now()}`,
145
+ filename: urlValue.split("/").pop() || "file",
146
+ url: urlValue,
147
+ mimeType: "application/octet-stream",
148
+ };
149
+ if (isMultiple) {
150
+ onChange([...currentValue, newItem]);
151
+ } else {
152
+ onChange(newItem);
153
+ }
154
+ setUrlValue("");
155
+ setShowUrlInput(false);
156
+ };
157
+
158
+ const removeItem = (index: number) => {
159
+ const newItems = [...currentValue];
160
+ newItems.splice(index, 1);
161
+ onChange(newItems);
162
+ };
163
+
164
+ const formatFileSize = (bytes?: number) => {
165
+ if (!bytes) return "";
166
+ const kb = bytes / 1024;
167
+ if (kb < 1024) return `${kb.toFixed(1)} KB`;
168
+ const mb = kb / 1024;
169
+ return `${mb.toFixed(1)} MB`;
170
+ };
171
+
172
+ const getFileIcon = (mimeType?: string) => {
173
+ if (!mimeType) return "📄";
174
+ if (mimeType.startsWith("video/")) return "🎬";
175
+ if (mimeType.startsWith("audio/")) return "🎵";
176
+ if (mimeType.includes("pdf")) return "📕";
177
+ if (mimeType.includes("word") || mimeType.includes("document")) return "📝";
178
+ if (mimeType.includes("spreadsheet") || mimeType.includes("excel"))
179
+ return "📊";
180
+ if (mimeType.includes("presentation") || mimeType.includes("powerpoint"))
181
+ return "📽️";
182
+ if (mimeType.includes("zip") || mimeType.includes("archive")) return "📦";
183
+ return "📄";
184
+ };
185
+
186
+ const renderPreview = (item: any, index: number) => (
187
+ <div
188
+ key={index}
189
+ className="flex items-center gap-3 p-3 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-surface)]"
190
+ >
191
+ <div className="text-xl">{getFileIcon(item.mimeType)}</div>
192
+ <div className="flex-1 min-w-0">
193
+ <div className="text-sm font-medium text-[var(--kyro-text-primary)] truncate">
194
+ {item.filename || item.title || "Untitled"}
195
+ </div>
196
+ {item.size && (
197
+ <div className="text-xs text-[var(--kyro-text-muted)]">
198
+ {formatFileSize(item.size)}
199
+ </div>
200
+ )}
201
+ </div>
202
+ <button
203
+ type="button"
204
+ onClick={() => removeItem(index)}
205
+ className="text-[var(--kyro-text-muted)] hover:text-red-500 p-1"
206
+ >
207
+ <svg
208
+ className="w-4 h-4"
209
+ fill="none"
210
+ stroke="currentColor"
211
+ viewBox="0 0 24 24"
212
+ >
213
+ <path
214
+ strokeLinecap="round"
215
+ strokeLinejoin="round"
216
+ strokeWidth="2"
217
+ d="M6 18L18 6M6 6l12 12"
218
+ />
219
+ </svg>
220
+ </button>
221
+ </div>
222
+ );
223
+
224
+ return (
225
+ <div className="space-y-3">
226
+ {currentValue.length > 0 && (
227
+ <div className="space-y-2">
228
+ {currentValue.map((item, index) => renderPreview(item, index))}
229
+ </div>
230
+ )}
231
+
232
+ {canAddMore && (
233
+ <>
234
+ <div className="flex gap-2 flex-wrap">
235
+ <button
236
+ type="button"
237
+ onClick={() => inputRef.current?.click()}
238
+ disabled={disabled || uploading}
239
+ className="px-3 py-1.5 text-xs rounded border border-dashed border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-sidebar-active)] transition-colors disabled:opacity-50"
240
+ >
241
+ {uploading ? "Uploading..." : `+ Upload ${fieldLabel}`}
242
+ </button>
243
+ <input
244
+ ref={inputRef}
245
+ type="file"
246
+ onChange={handleFileSelect}
247
+ className="hidden"
248
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar,.7z,.mp4,.mov,.avi,.mp3,.wav"
249
+ />
250
+ <button
251
+ type="button"
252
+ onClick={() => setShowPicker(true)}
253
+ disabled={disabled}
254
+ className="px-3 py-1.5 text-xs rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-sidebar-active)] transition-colors"
255
+ >
256
+ Library
257
+ </button>
258
+ <button
259
+ type="button"
260
+ onClick={() => setShowUrlInput(!showUrlInput)}
261
+ disabled={disabled}
262
+ className="px-3 py-1.5 text-xs rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-sidebar-active)] transition-colors"
263
+ >
264
+ URL
265
+ </button>
266
+ </div>
267
+
268
+ {showUrlInput && (
269
+ <div className="flex gap-2 items-center">
270
+ <input
271
+ ref={urlInputRef}
272
+ type="url"
273
+ placeholder="https://example.com/file.pdf"
274
+ value={urlValue}
275
+ onChange={(e) => {
276
+ setUrlValue(e.target.value);
277
+ setUrlError("");
278
+ }}
279
+ onKeyDown={(e) => e.key === "Enter" && addByUrl()}
280
+ className="flex-1 px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)]"
281
+ />
282
+ <button
283
+ type="button"
284
+ onClick={addByUrl}
285
+ className="px-3 py-2 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded text-sm font-medium"
286
+ >
287
+ Add
288
+ </button>
289
+ {urlError && (
290
+ <span className="text-xs text-red-500">{urlError}</span>
291
+ )}
292
+ </div>
293
+ )}
294
+
295
+ {showPicker && (
296
+ <div className="border border-[var(--kyro-border)] rounded-lg p-4 space-y-3 bg-[var(--kyro-surface)]">
297
+ <div className="flex items-center justify-between">
298
+ <h3 className="text-sm font-medium">Select File</h3>
299
+ <button
300
+ type="button"
301
+ onClick={() => setShowPicker(false)}
302
+ className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]"
303
+ >
304
+ Close
305
+ </button>
306
+ </div>
307
+
308
+ {folders.length > 0 && (
309
+ <div className="flex gap-1 flex-wrap">
310
+ <button
311
+ type="button"
312
+ onClick={() => setSelectedFolder("")}
313
+ className={`px-2 py-1 text-xs rounded ${
314
+ !selectedFolder
315
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
316
+ : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)]"
317
+ }`}
318
+ >
319
+ All
320
+ </button>
321
+ {folders.map((folder) => (
322
+ <button
323
+ key={folder.path}
324
+ type="button"
325
+ onClick={() => setSelectedFolder(folder.path)}
326
+ className={`px-2 py-1 text-xs rounded ${
327
+ selectedFolder === folder.path
328
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
329
+ : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)]"
330
+ }`}
331
+ >
332
+ {folder.name}
333
+ </button>
334
+ ))}
335
+ </div>
336
+ )}
337
+
338
+ {mediaLoading ? (
339
+ <div className="text-center py-4 text-[var(--kyro-text-muted)]">
340
+ Loading...
341
+ </div>
342
+ ) : (
343
+ <div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
344
+ {mediaItems.map((item) => {
345
+ const isSelected = currentValue.some(
346
+ (v) => v.url === item.url,
347
+ );
348
+ return (
349
+ <button
350
+ key={item.id}
351
+ type="button"
352
+ onClick={() => {
353
+ if (isMultiple) {
354
+ onChange([...currentValue, item]);
355
+ } else {
356
+ onChange(item);
357
+ }
358
+ setShowPicker(false);
359
+ }}
360
+ disabled={isSelected}
361
+ className={`p-2 rounded border text-left hover:border-[var(--kyro-sidebar-active)] transition-colors ${
362
+ isSelected
363
+ ? "border-[var(--kyro-sidebar-active)] bg-[var(--kyro-sidebar-active)]/10"
364
+ : "border-[var(--kyro-border)]"
365
+ }`}
366
+ >
367
+ <div className="text-lg mb-1 text-center">
368
+ {getFileIcon(item.mimeType)}
369
+ </div>
370
+ <div className="text-[10px] text-[var(--kyro-text-muted)] truncate">
371
+ {item.filename}
372
+ </div>
373
+ </button>
374
+ );
375
+ })}
376
+ </div>
377
+ )}
378
+
379
+ {mediaItems.length === 0 && !mediaLoading && (
380
+ <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm">
381
+ No files found
382
+ </div>
383
+ )}
384
+ </div>
385
+ )}
386
+ </>
387
+ )}
388
+ </div>
389
+ );
390
+ }