@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
@@ -0,0 +1,361 @@
1
+ import React, { useState, useCallback, useMemo, useEffect } from "react";
2
+ import type { JSONField as JSONFieldType } from "@kyro-cms/core";
3
+
4
+ interface JSONFieldProps {
5
+ field: JSONFieldType;
6
+ value?: any;
7
+ onChange?: (value: any) => void;
8
+ error?: string;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ interface TreeNode {
13
+ key: string;
14
+ value: any;
15
+ type: "object" | "array" | "string" | "number" | "boolean" | "null";
16
+ path: string[];
17
+ collapsed?: boolean;
18
+ }
19
+
20
+ export const JSONField: React.FC<JSONFieldProps> = ({
21
+ field,
22
+ value,
23
+ onChange,
24
+ error,
25
+ disabled,
26
+ }) => {
27
+ const [textValue, setTextValue] = useState<string>(() => {
28
+ if (typeof value === "string") return value;
29
+ return JSON.stringify(value || {}, null, 2);
30
+ });
31
+ const [parseError, setParseError] = useState<string | null>(null);
32
+ const [viewMode, setViewMode] = useState<"text" | "tree">("text");
33
+ const [isMounted, setIsMounted] = useState(false);
34
+ const [isDark, setIsDark] = useState(false);
35
+
36
+ useEffect(() => {
37
+ setIsMounted(true);
38
+ setIsDark(document.documentElement.classList.contains("dark"));
39
+
40
+ // Listen for theme changes
41
+ const observer = new MutationObserver(() => {
42
+ setIsDark(document.documentElement.classList.contains("dark"));
43
+ });
44
+
45
+ observer.observe(document.documentElement, {
46
+ attributes: true,
47
+ attributeFilter: ["class"],
48
+ });
49
+
50
+ return () => observer.disconnect();
51
+ }, []);
52
+
53
+ const handleTextChange = useCallback(
54
+ (text: string) => {
55
+ setTextValue(text);
56
+ try {
57
+ const parsed = JSON.parse(text);
58
+ setParseError(null);
59
+ onChange?.(parsed);
60
+ } catch (e) {
61
+ if (text.trim()) {
62
+ setParseError((e as Error).message);
63
+ }
64
+ }
65
+ },
66
+ [onChange],
67
+ );
68
+
69
+ const formatJSON = useCallback(() => {
70
+ try {
71
+ const parsed = JSON.parse(textValue);
72
+ const formatted = JSON.stringify(parsed, null, 2);
73
+ setTextValue(formatted);
74
+ onChange?.(parsed);
75
+ setParseError(null);
76
+ } catch (e) {
77
+ setParseError((e as Error).message);
78
+ }
79
+ }, [textValue, onChange]);
80
+
81
+ const minifyJSON = useCallback(() => {
82
+ try {
83
+ const parsed = JSON.parse(textValue);
84
+ const minified = JSON.stringify(parsed);
85
+ setTextValue(minified);
86
+ onChange?.(parsed);
87
+ setParseError(null);
88
+ } catch (e) {
89
+ setParseError((e as Error).message);
90
+ }
91
+ }, [textValue, onChange]);
92
+
93
+ const treeData = useMemo(() => {
94
+ try {
95
+ const parsed = JSON.parse(textValue);
96
+ return buildTree(parsed, []);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }, [textValue]);
101
+
102
+ if (!isMounted) {
103
+ return (
104
+ <div className="kyro-form-field">
105
+ <label className="kyro-form-label">
106
+ {field.label || field.name}
107
+ {field.required && (
108
+ <span className="kyro-form-label-required">*</span>
109
+ )}
110
+ </label>
111
+ <div className="h-[200px] bg-[var(--kyro-surface)] animate-pulse rounded-md border border-[var(--kyro-border)]" />
112
+ </div>
113
+ );
114
+ }
115
+
116
+ return (
117
+ <div className="kyro-form-field">
118
+ <div className="flex items-center justify-between mb-2">
119
+ <label className="kyro-form-label">
120
+ {field.label || field.name}
121
+ {field.required && (
122
+ <span className="kyro-form-label-required">*</span>
123
+ )}
124
+ </label>
125
+ <div className="flex items-center gap-2">
126
+ {/* View mode toggle */}
127
+ <div className="flex rounded-md overflow-hidden border border-[var(--kyro-border)]">
128
+ <button
129
+ type="button"
130
+ onClick={() => setViewMode("text")}
131
+ className={`px-3 py-1 text-xs font-medium transition-colors ${
132
+ viewMode === "text"
133
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
134
+ : "bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
135
+ }`}
136
+ >
137
+ Text
138
+ </button>
139
+ <button
140
+ type="button"
141
+ onClick={() => setViewMode("tree")}
142
+ className={`px-3 py-1 text-xs font-medium transition-colors ${
143
+ viewMode === "tree"
144
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
145
+ : "bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
146
+ }`}
147
+ >
148
+ Tree
149
+ </button>
150
+ </div>
151
+ </div>
152
+ </div>
153
+
154
+ {/* Action buttons */}
155
+ <div className="flex items-center gap-2 mb-2">
156
+ <button
157
+ type="button"
158
+ onClick={formatJSON}
159
+ className="px-3 py-1.5 text-xs font-medium bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] rounded-md hover:bg-[var(--kyro-surface)] hover:text-[var(--kyro-text-primary)] transition-colors"
160
+ >
161
+ Format
162
+ </button>
163
+ <button
164
+ type="button"
165
+ onClick={minifyJSON}
166
+ className="px-3 py-1.5 text-xs font-medium bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] rounded-md hover:bg-[var(--kyro-surface)] hover:text-[var(--kyro-text-primary)] transition-colors"
167
+ >
168
+ Minify
169
+ </button>
170
+ <button
171
+ type="button"
172
+ onClick={() => {
173
+ navigator.clipboard.writeText(textValue);
174
+ }}
175
+ className="px-3 py-1.5 text-xs font-medium bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] rounded-md hover:bg-[var(--kyro-surface)] hover:text-[var(--kyro-text-primary)] transition-colors"
176
+ >
177
+ Copy
178
+ </button>
179
+ {parseError && (
180
+ <span className="text-xs text-red-500 ml-auto">{parseError}</span>
181
+ )}
182
+ </div>
183
+
184
+ {/* Editor area */}
185
+ <div
186
+ className={`border border-[var(--kyro-border)] rounded-md overflow-hidden ${
187
+ disabled ? "opacity-50 cursor-not-allowed" : ""
188
+ } ${error || parseError ? "border-red-500" : ""}`}
189
+ >
190
+ {viewMode === "text" ? (
191
+ <textarea
192
+ value={textValue}
193
+ onChange={(e) => handleTextChange(e.target.value)}
194
+ disabled={disabled}
195
+ rows={8}
196
+ className={`w-full p-4 font-mono text-sm resize-y focus:outline-none ${
197
+ isDark
198
+ ? "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"
199
+ : "bg-white text-gray-900"
200
+ }`}
201
+ placeholder='{"key": "value"}'
202
+ />
203
+ ) : (
204
+ <div
205
+ className={`p-4 max-h-[400px] overflow-auto ${
206
+ isDark
207
+ ? "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"
208
+ : "bg-white text-gray-900"
209
+ }`}
210
+ >
211
+ {treeData ? (
212
+ <TreeView data={treeData} />
213
+ ) : (
214
+ <p className="text-sm text-[var(--kyro-text-muted)] italic">
215
+ Invalid JSON - switch to Text view to fix
216
+ </p>
217
+ )}
218
+ </div>
219
+ )}
220
+ </div>
221
+
222
+ {field.admin?.description && !error && !parseError && (
223
+ <p className="kyro-form-help">{field.admin.description}</p>
224
+ )}
225
+ {(error || parseError) && (
226
+ <p className="kyro-form-error">{error || parseError}</p>
227
+ )}
228
+ </div>
229
+ );
230
+ };
231
+
232
+ // Tree view component
233
+ const TreeView: React.FC<{ data: TreeNode }> = ({ data }) => {
234
+ const [collapsed, setCollapsed] = useState(false);
235
+
236
+ const renderValue = (node: TreeNode) => {
237
+ if (node.type === "object") {
238
+ const entries = Object.entries(node.value);
239
+ return (
240
+ <div className="pl-4 border-l border-[var(--kyro-border)]">
241
+ {entries.map(([key, val]) => (
242
+ <TreeNodeView
243
+ key={key}
244
+ name={key}
245
+ value={val}
246
+ path={[...node.path, key]}
247
+ />
248
+ ))}
249
+ </div>
250
+ );
251
+ }
252
+ if (node.type === "array") {
253
+ return (
254
+ <div className="pl-4 border-l border-[var(--kyro-border)]">
255
+ {node.value.map((item: any, index: number) => (
256
+ <TreeNodeView
257
+ key={index}
258
+ name={String(index)}
259
+ value={item}
260
+ path={[...node.path, String(index)]}
261
+ />
262
+ ))}
263
+ </div>
264
+ );
265
+ }
266
+ return null;
267
+ };
268
+
269
+ const getTypeColor = (type: string) => {
270
+ switch (type) {
271
+ case "string":
272
+ return "text-green-600";
273
+ case "number":
274
+ return "text-blue-600";
275
+ case "boolean":
276
+ return "text-purple-600";
277
+ case "null":
278
+ return "text-gray-500";
279
+ default:
280
+ return "text-[var(--kyro-text-primary)]";
281
+ }
282
+ };
283
+
284
+ const getValueDisplay = (item: TreeNode) => {
285
+ if (item.type === "string") return `"${item.value}"`;
286
+ if (item.type === "null") return "null";
287
+ return String(item.value);
288
+ };
289
+
290
+ const getPreview = (item: TreeNode) => {
291
+ if (item.type === "object") {
292
+ const keys = Object.keys(item.value);
293
+ return `{${keys.length} ${keys.length === 1 ? "key" : "keys"}}`;
294
+ }
295
+ if (item.type === "array") {
296
+ return `[${item.value.length} ${item.value.length === 1 ? "item" : "items"}]`;
297
+ }
298
+ return null;
299
+ };
300
+
301
+ const isExpandable = data.type === "object" || data.type === "array";
302
+
303
+ return (
304
+ <div className="py-0.5">
305
+ <div
306
+ className="flex items-center gap-2 cursor-pointer hover:bg-[var(--kyro-surface-accent)] rounded px-1 -ml-1"
307
+ onClick={() => isExpandable && setCollapsed(!collapsed)}
308
+ >
309
+ {isExpandable && (
310
+ <span className="text-[var(--kyro-text-muted)] text-xs">
311
+ {collapsed ? "▶" : "▼"}
312
+ </span>
313
+ )}
314
+ <span className="font-medium text-[var(--kyro-text-primary)]">
315
+ {data.key}
316
+ </span>
317
+ <span className="text-[var(--kyro-text-muted)]">:</span>
318
+ {isExpandable ? (
319
+ <span className="text-xs text-[var(--kyro-text-muted)]">
320
+ {collapsed ? getPreview(data) : data.type === "object" ? "{" : "["}
321
+ </span>
322
+ ) : (
323
+ <span className={`font-mono text-sm ${getTypeColor(data.type)}`}>
324
+ {getValueDisplay(data)}
325
+ </span>
326
+ )}
327
+ </div>
328
+ {!collapsed && isExpandable && renderValue(data)}
329
+ {collapsed && isExpandable && (
330
+ <span className="text-[var(--kyro-text-muted)] ml-6 text-xs">
331
+ {data.type === "object" ? "}" : "]"}
332
+ </span>
333
+ )}
334
+ </div>
335
+ );
336
+ };
337
+
338
+ const TreeNodeView: React.FC<{ name: string; value: any; path: string[] }> = ({
339
+ name,
340
+ value,
341
+ path,
342
+ }) => {
343
+ const type = getType(value);
344
+ const treeNode: TreeNode = { key: name, value, type, path };
345
+ return <TreeView data={treeNode} />;
346
+ };
347
+
348
+ function getType(value: any): TreeNode["type"] {
349
+ if (value === null) return "null";
350
+ if (Array.isArray(value)) return "array";
351
+ return typeof value as TreeNode["type"];
352
+ }
353
+
354
+ function buildTree(value: any, path: string[]): TreeNode {
355
+ return {
356
+ key: path[path.length - 1] || "root",
357
+ value,
358
+ type: getType(value),
359
+ path,
360
+ };
361
+ }
@@ -0,0 +1,282 @@
1
+ import React, { useState, useCallback, useMemo, useEffect } from "react";
2
+ import type { MarkdownField as MarkdownFieldType } from "@kyro-cms/core";
3
+
4
+ interface MarkdownFieldProps {
5
+ field: MarkdownFieldType;
6
+ value?: string;
7
+ onChange?: (value: string) => void;
8
+ error?: string;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ // Simple markdown parser for basic formatting
13
+ function parseMarkdown(text: string): string {
14
+ if (!text) return "";
15
+
16
+ let html = text
17
+ // Escape HTML
18
+ .replace(/&/g, "&amp;")
19
+ .replace(/</g, "&lt;")
20
+ .replace(/>/g, "&gt;")
21
+
22
+ // Code blocks
23
+ .replace(
24
+ /```(\w+)?\n([\s\S]*?)```/g,
25
+ '<pre class="bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto my-2"><code>$2</code></pre>',
26
+ )
27
+
28
+ // Inline code
29
+ .replace(
30
+ /`([^`]+)`/g,
31
+ '<code class="bg-gray-100 dark:bg-gray-800 px-1 rounded text-sm font-mono">$1</code>',
32
+ )
33
+
34
+ // Headers
35
+ .replace(
36
+ /^### (.+)$/gm,
37
+ '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>',
38
+ )
39
+ .replace(
40
+ /^## (.+)$/gm,
41
+ '<h2 class="text-xl font-semibold mt-4 mb-2">$1</h2>',
42
+ )
43
+ .replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-4 mb-2">$1</h1>')
44
+
45
+ // Bold and Italic
46
+ .replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>")
47
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
48
+ .replace(/\*(.+?)\*/g, "<em>$1</em>")
49
+ .replace(/___(.+?)___/g, "<strong><em>$1</em></strong>")
50
+ .replace(/__(.+?)__/g, "<strong>$1</strong>")
51
+ .replace(/_(.+?)_/g, "<em>$1</em>")
52
+
53
+ // Strikethrough
54
+ .replace(/~~(.+?)~~/g, "<del>$1</del>")
55
+
56
+ // Blockquotes
57
+ .replace(
58
+ /^> (.+)$/gm,
59
+ '<blockquote class="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic my-2 text-gray-600 dark:text-gray-400">$1</blockquote>',
60
+ )
61
+
62
+ // Links
63
+ .replace(
64
+ /\[([^\]]+)\]\(([^)]+)\)/g,
65
+ '<a href="$2" class="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>',
66
+ )
67
+
68
+ // Images
69
+ .replace(
70
+ /!\[([^\]]*)\]\(([^)]+)\)/g,
71
+ '<img src="$2" alt="$1" class="max-w-full h-auto rounded my-2" />',
72
+ )
73
+
74
+ // Horizontal rules
75
+ .replace(
76
+ /^---$/gm,
77
+ '<hr class="border-gray-300 dark:border-gray-600 my-4" />',
78
+ )
79
+ .replace(
80
+ /^\*\*\*$/gm,
81
+ '<hr class="border-gray-300 dark:border-gray-600 my-4" />',
82
+ )
83
+
84
+ // Unordered lists
85
+ .replace(/^[\*\-] (.+)$/gm, '<li class="ml-4">$1</li>')
86
+
87
+ // Ordered lists
88
+ .replace(/^\d+\. (.+)$/gm, '<li class="ml-4 list-decimal">$1</li>')
89
+
90
+ // Paragraphs (lines that don't start with special chars)
91
+ .replace(/^(?!<[a-z]|$)(.+)$/gm, '<p class="my-2">$1</p>')
92
+
93
+ // Clean up empty paragraphs
94
+ .replace(/<p class="my-2"><\/p>/g, "")
95
+
96
+ // Wrap consecutive list items in ul/ol
97
+ .replace(
98
+ /(<li class="ml-4">.*<\/li>\n?)+/g,
99
+ '<ul class="list-disc pl-6 my-2">$&</ul>',
100
+ );
101
+
102
+ return html;
103
+ }
104
+
105
+ export const MarkdownField: React.FC<MarkdownFieldProps> = ({
106
+ field,
107
+ value = "",
108
+ onChange,
109
+ error,
110
+ disabled,
111
+ }) => {
112
+ const [showPreview, setShowPreview] = useState(false);
113
+ const [isMounted, setIsMounted] = useState(false);
114
+ const [isDark, setIsDark] = useState(false);
115
+
116
+ useEffect(() => {
117
+ setIsMounted(true);
118
+ setIsDark(document.documentElement.classList.contains("dark"));
119
+
120
+ // Listen for theme changes
121
+ const observer = new MutationObserver(() => {
122
+ setIsDark(document.documentElement.classList.contains("dark"));
123
+ });
124
+
125
+ observer.observe(document.documentElement, {
126
+ attributes: true,
127
+ attributeFilter: ["class"],
128
+ });
129
+
130
+ return () => observer.disconnect();
131
+ }, []);
132
+
133
+ const handleChange = useCallback(
134
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
135
+ onChange?.(e.target.value);
136
+ },
137
+ [onChange],
138
+ );
139
+
140
+ const wordCount = useMemo(() => {
141
+ if (!value) return 0;
142
+ return value.trim().split(/\s+/).filter(Boolean).length;
143
+ }, [value]);
144
+
145
+ const characterCount = value?.length || 0;
146
+
147
+ if (!isMounted) {
148
+ return (
149
+ <div className="kyro-form-field">
150
+ <label className="kyro-form-label">
151
+ {field.label || field.name}
152
+ {field.required && (
153
+ <span className="kyro-form-label-required">*</span>
154
+ )}
155
+ </label>
156
+ <div className="h-[200px] bg-[var(--kyro-surface)] animate-pulse rounded-md border border-[var(--kyro-border)]" />
157
+ </div>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <div
163
+ className="kyro-form-field"
164
+ style={{ position: "relative", zIndex: 10, pointerEvents: "auto" }}
165
+ >
166
+ <div className="flex items-center justify-between mb-2">
167
+ <label className="kyro-form-label">
168
+ {field.label || field.name}
169
+ {field.required && (
170
+ <span className="kyro-form-label-required">*</span>
171
+ )}
172
+ </label>
173
+ <div className="flex items-center gap-3">
174
+ {/* Stats */}
175
+ <span className="text-xs text-[var(--kyro-text-muted)]">
176
+ {wordCount} words • {characterCount} chars
177
+ </span>
178
+
179
+ {/* Preview toggle */}
180
+ <div className="flex rounded-md overflow-hidden border border-[var(--kyro-border)]">
181
+ <button
182
+ type="button"
183
+ onClick={() => setShowPreview(false)}
184
+ className={`px-3 py-1 text-xs font-medium transition-colors ${
185
+ !showPreview
186
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
187
+ : "bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
188
+ }`}
189
+ >
190
+ Write
191
+ </button>
192
+ <button
193
+ type="button"
194
+ onClick={() => setShowPreview(true)}
195
+ className={`px-3 py-1 text-xs font-medium transition-colors ${
196
+ showPreview
197
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
198
+ : "bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
199
+ }`}
200
+ >
201
+ Preview
202
+ </button>
203
+ </div>
204
+ </div>
205
+ </div>
206
+
207
+ <div
208
+ style={{ pointerEvents: "auto", position: "relative", zIndex: 50 }}
209
+ className={`border border-[var(--kyro-border)] rounded-md overflow-hidden ${
210
+ disabled ? "opacity-50 cursor-not-allowed" : ""
211
+ } ${error ? "border-red-500" : ""}`}
212
+ >
213
+ {!showPreview ? (
214
+ <textarea
215
+ value={value}
216
+ onChange={handleChange}
217
+ disabled={disabled}
218
+ rows={12}
219
+ style={{ pointerEvents: "auto", cursor: "text", zIndex: 100 }}
220
+ className={`w-full p-4 font-mono text-sm resize-y focus:outline-none ${
221
+ isDark
222
+ ? "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"
223
+ : "bg-white text-gray-900"
224
+ }`}
225
+ placeholder="Enter markdown content...
226
+
227
+ # Heading 1
228
+ ## Heading 2
229
+
230
+ **Bold text** and *italic text*
231
+
232
+ - List item 1
233
+ - List item 2
234
+
235
+ [Link text](https://example.com)
236
+
237
+ `inline code`
238
+
239
+ ```
240
+ code block
241
+ ```"
242
+ />
243
+ ) : (
244
+ <div
245
+ className={`p-6 min-h-[300px] overflow-auto ${
246
+ isDark
247
+ ? "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"
248
+ : "bg-white text-gray-900"
249
+ }`}
250
+ >
251
+ {value ? (
252
+ <div
253
+ className="prose prose-sm dark:prose-invert max-w-none"
254
+ dangerouslySetInnerHTML={{ __html: parseMarkdown(value) }}
255
+ />
256
+ ) : (
257
+ <p className="text-[var(--kyro-text-muted)] italic">
258
+ Nothing to preview
259
+ </p>
260
+ )}
261
+ </div>
262
+ )}
263
+ </div>
264
+
265
+ {/* Toolbar with markdown hints */}
266
+ {!showPreview && (
267
+ <div className="flex items-center gap-4 mt-2 text-xs text-[var(--kyro-text-muted)]">
268
+ <span>**bold**</span>
269
+ <span>*italic*</span>
270
+ <span>`code`</span>
271
+ <span>[link](url)</span>
272
+ <span># heading</span>
273
+ </div>
274
+ )}
275
+
276
+ {field.admin?.description && !error && (
277
+ <p className="kyro-form-help">{field.admin.description}</p>
278
+ )}
279
+ {error && <p className="kyro-form-error">{error}</p>}
280
+ </div>
281
+ );
282
+ };