@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
@@ -1,30 +1,58 @@
1
- import React, { useState, useCallback } from "react";
1
+ import React, {
2
+ useState,
3
+ useCallback,
4
+ useRef,
5
+ useEffect,
6
+ useMemo,
7
+ } from "react";
2
8
  import {
3
9
  EditorProvider,
4
10
  PortableTextEditable,
5
11
  useEditor,
12
+ type RenderStyleFunction,
13
+ type RenderDecoratorFunction,
14
+ type RenderBlockFunction,
15
+ type RenderListItemFunction,
16
+ type RenderAnnotationFunction,
6
17
  } from "@portabletext/editor";
7
18
  import { defineSchema } from "@portabletext/schema";
8
19
  import { EventListenerPlugin } from "@portabletext/editor/plugins";
20
+ import {
21
+ useToolbarSchema,
22
+ useDecoratorButton,
23
+ useListButton,
24
+ useStyleSelector,
25
+ useHistoryButtons,
26
+ useAnnotationButton,
27
+ useAnnotationPopover,
28
+ } from "@portabletext/toolbar";
29
+ import {
30
+ Bold,
31
+ Italic,
32
+ Underline,
33
+ Strikethrough,
34
+ Code,
35
+ Link,
36
+ List,
37
+ ListOrdered,
38
+ Undo,
39
+ Redo,
40
+ ChevronDown,
41
+ X,
42
+ ExternalLink,
43
+ } from "lucide-react";
9
44
 
10
45
  interface EditorClientProps {
11
46
  initialValue: any[];
12
47
  onChange: (blocks: any[]) => void;
13
48
  disabled?: boolean;
14
- theme: any;
15
49
  }
16
50
 
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
51
  function sanitizeInitialValue(value: any): any[] {
23
52
  if (!value || !Array.isArray(value)) return [];
24
53
  return value.filter((block) => {
25
54
  if (!block || typeof block !== "object") return false;
26
55
  if (!block._type) return false;
27
- // For text blocks, ensure every span has a string `text` property
28
56
  if (block._type === "block" && Array.isArray(block.children)) {
29
57
  block.children = block.children.map((child: any) => ({
30
58
  ...child,
@@ -36,7 +64,6 @@ function sanitizeInitialValue(value: any): any[] {
36
64
  });
37
65
  }
38
66
 
39
- // Define basic portable text schema
40
67
  const schemaDefinition = defineSchema({
41
68
  decorators: [
42
69
  { name: "strong", title: "Bold" },
@@ -46,161 +73,397 @@ const schemaDefinition = defineSchema({
46
73
  { name: "code", title: "Code" },
47
74
  ],
48
75
  styles: [
49
- { name: "normal", title: "Paragraph" },
50
- { name: "h1", title: "Heading 1" },
51
- { name: "h2", title: "Heading 2" },
52
- { name: "h3", title: "Heading 3" },
76
+ { name: "normal", title: "Normal" },
77
+ { name: "h1", title: "H1" },
78
+ { name: "h2", title: "H2" },
79
+ { name: "h3", title: "H3" },
53
80
  { name: "blockquote", title: "Quote" },
54
81
  ],
55
82
  lists: [
56
- { name: "bullet", title: "Bullet List" },
57
- { name: "number", title: "Numbered List" },
83
+ { name: "bullet", title: "Bullet" },
84
+ { name: "number", title: "Number" },
85
+ ],
86
+ annotations: [
87
+ {
88
+ name: "link",
89
+ title: "Link",
90
+ fields: [{ name: "href", type: "string", title: "URL" }],
91
+ },
58
92
  ],
59
- annotations: [],
60
93
  inlineObjects: [],
61
94
  blockObjects: [],
62
95
  });
63
96
 
64
- const Toolbar: React.FC<{ onInsertTag: (tag: string) => void }> = ({
65
- onInsertTag,
97
+ const renderStyle: RenderStyleFunction = (props) => {
98
+ if (props.schemaType.value === "h1") {
99
+ return <h1 className="text-2xl font-bold mb-2">{props.children}</h1>;
100
+ }
101
+ if (props.schemaType.value === "h2") {
102
+ return <h2 className="text-xl font-bold mb-2">{props.children}</h2>;
103
+ }
104
+ if (props.schemaType.value === "h3") {
105
+ return <h3 className="text-lg font-semibold mb-1">{props.children}</h3>;
106
+ }
107
+ if (props.schemaType.value === "blockquote") {
108
+ return (
109
+ <blockquote className="border-l-2 border-[var(--kyro-primary)] pl-4 italic text-[var(--kyro-text-muted)] my-2">
110
+ {props.children}
111
+ </blockquote>
112
+ );
113
+ }
114
+ return <>{props.children}</>;
115
+ };
116
+
117
+ const renderDecorator: RenderDecoratorFunction = (props) => {
118
+ if (props.value === "strong") {
119
+ return <strong>{props.children}</strong>;
120
+ }
121
+ if (props.value === "em") {
122
+ return <em>{props.children}</em>;
123
+ }
124
+ if (props.value === "underline") {
125
+ return <u>{props.children}</u>;
126
+ }
127
+ if (props.value === "strikeThrough") {
128
+ return <s>{props.children}</s>;
129
+ }
130
+ if (props.value === "code") {
131
+ return (
132
+ <code className="px-1 py-0.5 rounded bg-[var(--kyro-surface-accent)] text-[var(--kyro-primary)] text-sm font-mono">
133
+ {props.children}
134
+ </code>
135
+ );
136
+ }
137
+ return <>{props.children}</>;
138
+ };
139
+
140
+ const renderBlock: RenderBlockFunction = (props) => {
141
+ return <div>{props.children}</div>;
142
+ };
143
+
144
+ const renderListItem: RenderListItemFunction = (props) => {
145
+ if (props.schemaType.value === "bullet") {
146
+ return <li className="list-disc ml-4">{props.children}</li>;
147
+ }
148
+ if (props.schemaType.value === "number") {
149
+ return <li className="list-decimal ml-4">{props.children}</li>;
150
+ }
151
+ return <li>{props.children}</li>;
152
+ };
153
+
154
+ const renderAnnotation: RenderAnnotationFunction = (props) => {
155
+ if (props.schemaType.name === "link") {
156
+ return (
157
+ <a
158
+ href={props.value.href as string}
159
+ className="text-[var(--kyro-primary)] underline hover:opacity-80"
160
+ target="_blank"
161
+ rel="noopener noreferrer"
162
+ >
163
+ {props.children}
164
+ </a>
165
+ );
166
+ }
167
+ return <>{props.children}</>;
168
+ };
169
+
170
+ function FocusRestoringButton({
171
+ onClick,
172
+ children,
173
+ ...props
174
+ }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
175
+ const editor = useEditor();
176
+ return (
177
+ <button
178
+ type="button"
179
+ onClick={(e) => {
180
+ onClick?.(e);
181
+ editor.send({ type: "focus" });
182
+ }}
183
+ {...props}
184
+ >
185
+ {children}
186
+ </button>
187
+ );
188
+ }
189
+
190
+ const decoratorIcons: Record<
191
+ string,
192
+ React.ComponentType<{ className?: string }>
193
+ > = {
194
+ strong: Bold,
195
+ em: Italic,
196
+ underline: Underline,
197
+ strikeThrough: Strikethrough,
198
+ code: Code,
199
+ };
200
+
201
+ const DecoratorButton: React.FC<{ name: string; title: string }> = ({
202
+ name,
203
+ title,
204
+ }) => {
205
+ const schema = useToolbarSchema({});
206
+ const decoratorSchema = schema.decorators?.find((d) => d.name === name);
207
+ if (!decoratorSchema) return null;
208
+ const { snapshot, send } = useDecoratorButton({
209
+ schemaType: decoratorSchema,
210
+ });
211
+ const Icon = decoratorIcons[name];
212
+ const isActive =
213
+ snapshot.matches({ enabled: "active" }) ||
214
+ snapshot.matches({ disabled: "active" });
215
+ const isEnabled = snapshot.matches("enabled");
216
+
217
+ return (
218
+ <FocusRestoringButton
219
+ disabled={!isEnabled}
220
+ data-state={isActive ? "on" : "off"}
221
+ onClick={() => send({ type: "toggle" })}
222
+ title={title}
223
+ className="p-1.5 rounded transition-colors disabled:opacity-30 hover:bg-[var(--kyro-surface-accent)] data-[state=on]:bg-[var(--kyro-primary)] data-[state=on]:text-[var(--kyro-sidebar-text-active)]"
224
+ >
225
+ {Icon && <Icon className="w-4 h-4" />}
226
+ </FocusRestoringButton>
227
+ );
228
+ };
229
+
230
+ const ListButton: React.FC<{ name: string; title: string }> = ({
231
+ name,
232
+ title,
233
+ }) => {
234
+ const schema = useToolbarSchema({});
235
+ const listSchema = schema.lists?.find((l) => l.name === name);
236
+ if (!listSchema) return null;
237
+ const { snapshot, send } = useListButton({ schemaType: listSchema });
238
+ const Icon = name === "bullet" ? List : ListOrdered;
239
+ const isActive =
240
+ snapshot.matches({ enabled: "active" }) ||
241
+ snapshot.matches({ disabled: "active" });
242
+ const isEnabled = snapshot.matches("enabled");
243
+
244
+ return (
245
+ <FocusRestoringButton
246
+ disabled={!isEnabled}
247
+ data-state={isActive ? "on" : "off"}
248
+ onClick={() => send({ type: "toggle" })}
249
+ title={title}
250
+ className="p-1.5 rounded transition-colors disabled:opacity-30 hover:bg-[var(--kyro-surface-accent)] data-[state=on]:bg-[var(--kyro-primary)] data-[state=on]:text-[var(--kyro-sidebar-text-active)]"
251
+ >
252
+ {Icon && <Icon className="w-4 h-4" />}
253
+ </FocusRestoringButton>
254
+ );
255
+ };
256
+
257
+ const AnnotationButton: React.FC<{ name: string; title: string }> = ({
258
+ name,
259
+ title,
66
260
  }) => {
67
- const textColor = "#374151";
68
- const textMuted = "#9ca3af";
261
+ const schema = useToolbarSchema({});
262
+ const annotationSchema = schema.annotations?.find((a) => a.name === name);
263
+ if (!annotationSchema) return null;
264
+ const { snapshot, send } = useAnnotationButton({
265
+ schemaType: annotationSchema,
266
+ });
267
+ const isActive =
268
+ snapshot.matches({ enabled: "active" }) ||
269
+ snapshot.matches({ disabled: "active" });
270
+ const isEnabled = snapshot.matches("enabled");
271
+ const isShowingDialog = snapshot.matches({
272
+ enabled: { inactive: "showing dialog" },
273
+ });
274
+
275
+ return (
276
+ <FocusRestoringButton
277
+ disabled={!isEnabled}
278
+ data-state={isActive ? "on" : "off"}
279
+ onClick={() =>
280
+ send({
281
+ type: isShowingDialog ? "close dialog" : "open dialog",
282
+ })
283
+ }
284
+ title={title}
285
+ className="p-1.5 rounded transition-colors disabled:opacity-30 hover:bg-[var(--kyro-surface-accent)] data-[state=on]:bg-[var(--kyro-primary)] data-[state=on]:text-[var(--kyro-sidebar-text-active)]"
286
+ >
287
+ {name === "link" && <Link className="w-4 h-4" />}
288
+ </FocusRestoringButton>
289
+ );
290
+ };
291
+
292
+ const LinkDialog: React.FC = () => {
293
+ const schema = useToolbarSchema({});
294
+ const popover = useAnnotationPopover({
295
+ schemaTypes: schema.annotations || [],
296
+ });
297
+ const dialogRef = useRef<HTMLDivElement>(null);
298
+ const inputRef = useRef<HTMLInputElement>(null);
299
+
300
+ useEffect(() => {
301
+ if (popover.snapshot.matches({ enabled: "active" }) && inputRef.current) {
302
+ inputRef.current.focus();
303
+ }
304
+ }, [popover.snapshot]);
305
+
306
+ if (!popover.snapshot.matches({ enabled: "active" })) return null;
307
+
308
+ const activeAnnotations = popover.snapshot.context.annotations || [];
309
+ const activeLink = activeAnnotations.find(
310
+ (a) => a.schemaType.name === "link",
311
+ );
312
+ const currentHref = activeLink?.value?.href as string | undefined;
313
+
314
+ const handleSubmit = (e: React.FormEvent) => {
315
+ e.preventDefault();
316
+ const formData = new FormData(e.target as HTMLFormElement);
317
+ const href = formData.get("href") as string;
318
+
319
+ if (activeLink) {
320
+ if (href.trim()) {
321
+ popover.send({
322
+ type: "edit",
323
+ at: activeLink.at,
324
+ props: { href: href.trim() },
325
+ });
326
+ } else {
327
+ popover.send({
328
+ type: "remove",
329
+ schemaType: activeLink.schemaType,
330
+ });
331
+ }
332
+ } else {
333
+ if (href.trim()) {
334
+ popover.send({
335
+ type: "edit",
336
+ at: [] as any,
337
+ props: { href: href.trim() },
338
+ });
339
+ }
340
+ }
341
+ popover.send({ type: "close" });
342
+ };
343
+
344
+ const handleRemove = () => {
345
+ if (activeLink) {
346
+ popover.send({
347
+ type: "remove",
348
+ schemaType: activeLink.schemaType,
349
+ });
350
+ }
351
+ popover.send({ type: "close" });
352
+ };
69
353
 
70
354
  return (
71
355
  <div
72
- className="flex items-center gap-0.5 p-2 border-b flex-wrap"
73
- style={{ backgroundColor: "#f9fafb", borderColor: "#e5e7eb" }}
356
+ ref={dialogRef}
357
+ className="absolute top-full left-0 z-50 mt-1 w-72 p-3 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-bg-primary)] shadow-lg"
74
358
  >
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>
359
+ <div className="flex items-center justify-between mb-2">
360
+ <span className="text-sm font-medium text-[var(--kyro-text-primary)]">
361
+ Link
362
+ </span>
363
+ <button
364
+ type="button"
365
+ onClick={() => popover.send({ type: "close" })}
366
+ className="p-1 rounded hover:bg-[var(--kyro-surface-accent)]"
367
+ >
368
+ <X className="w-3.5 h-3.5 text-[var(--kyro-text-muted)]" />
369
+ </button>
370
+ </div>
371
+ <form onSubmit={handleSubmit} className="space-y-2">
372
+ <div className="flex gap-1.5">
373
+ <input
374
+ ref={inputRef}
375
+ name="href"
376
+ type="url"
377
+ defaultValue={currentHref || "https://"}
378
+ placeholder="Enter URL..."
379
+ className="flex-1 px-2.5 py-1.5 text-sm rounded border border-[var(--kyro-border)] bg-transparent text-[var(--kyro-text-primary)] placeholder:text-[var(--kyro-text-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-primary)]"
380
+ />
381
+ <button
382
+ type="submit"
383
+ className="px-2.5 py-1.5 text-sm rounded bg-[var(--kyro-primary)] text-[var(--kyro-sidebar-text-active)] hover:opacity-90"
384
+ >
385
+ {activeLink ? "Update" : "Add"}
386
+ </button>
387
+ </div>
388
+ {activeLink && (
389
+ <button
390
+ type="button"
391
+ onClick={handleRemove}
392
+ className="w-full text-xs text-[var(--kyro-error)] hover:opacity-80 flex items-center justify-center gap-1 py-1"
393
+ >
394
+ <ExternalLink className="w-3 h-3" />
395
+ Remove link
396
+ </button>
397
+ )}
398
+ </form>
399
+ </div>
400
+ );
401
+ };
120
402
 
121
- <div
122
- className="w-px h-6 mx-1"
123
- style={{ backgroundColor: textMuted + "40" }}
124
- />
403
+ const StyleSelector: React.FC = () => {
404
+ const schema = useToolbarSchema({});
405
+ const editor = useEditor();
406
+ const { snapshot, send } = useStyleSelector({
407
+ schemaTypes: schema.styles || [],
408
+ });
409
+ if (!snapshot.matches("enabled")) return null;
410
+ const activeStyle = snapshot.context.activeStyle || "normal";
125
411
 
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"
412
+ return (
413
+ <div className="relative">
414
+ <select
415
+ value={activeStyle}
416
+ onChange={(e) => {
417
+ send({ type: "toggle", style: e.target.value as any });
418
+ editor.send({ type: "focus" });
419
+ }}
420
+ className="appearance-none bg-transparent text-sm pr-6 pl-2 py-1 rounded hover:bg-[var(--kyro-surface-accent)] cursor-pointer focus:outline-none focus:ring-1 focus:ring-[var(--kyro-primary)]"
159
421
  >
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
- />
422
+ {schema.styles?.map((s) => (
423
+ <option key={s.name} value={s.name}>
424
+ {s.title}
425
+ </option>
426
+ ))}
427
+ </select>
428
+ <ChevronDown className="w-3 h-3 absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none text-[var(--kyro-text-muted)]" />
429
+ </div>
430
+ );
431
+ };
169
432
 
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"
433
+ const Toolbar: React.FC = () => {
434
+ const { snapshot: historySnapshot, send: historySend } = useHistoryButtons();
435
+ return (
436
+ <div className="relative flex items-center gap-0.5 p-1.5 border-b border-[var(--kyro-border)]">
437
+ <FocusRestoringButton
438
+ disabled={!historySnapshot.matches("enabled")}
439
+ onClick={() => historySend({ type: "history.undo" })}
440
+ title="Undo"
441
+ className="p-1.5 rounded transition-colors hover:bg-[var(--kyro-surface-accent)] disabled:opacity-30"
176
442
  >
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"
443
+ <Undo className="w-4 h-4 text-[var(--kyro-text-secondary)]" />
444
+ </FocusRestoringButton>
445
+ <FocusRestoringButton
446
+ disabled={!historySnapshot.matches("enabled")}
447
+ onClick={() => historySend({ type: "history.redo" })}
448
+ title="Redo"
449
+ className="p-1.5 rounded transition-colors hover:bg-[var(--kyro-surface-accent)] disabled:opacity-30"
193
450
  >
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>
451
+ <Redo className="w-4 h-4 text-[var(--kyro-text-secondary)]" />
452
+ </FocusRestoringButton>
453
+ <div className="w-px h-5 bg-[var(--kyro-border)] mx-1" />
454
+ <StyleSelector />
455
+ <div className="w-px h-5 bg-[var(--kyro-border)] mx-1" />
456
+ <DecoratorButton name="strong" title="Bold" />
457
+ <DecoratorButton name="em" title="Italic" />
458
+ <DecoratorButton name="underline" title="Underline" />
459
+ <DecoratorButton name="strikeThrough" title="Strikethrough" />
460
+ <DecoratorButton name="code" title="Code" />
461
+ <div className="w-px h-5 bg-[var(--kyro-border)] mx-1" />
462
+ <AnnotationButton name="link" title="Link" />
463
+ <div className="w-px h-5 bg-[var(--kyro-border)] mx-1" />
464
+ <ListButton name="bullet" title="Bullet List" />
465
+ <ListButton name="number" title="Numbered List" />
466
+ <LinkDialog />
204
467
  </div>
205
468
  );
206
469
  };
@@ -209,24 +472,18 @@ const EditorInner: React.FC<{
209
472
  onChange: (blocks: any[]) => void;
210
473
  disabled?: boolean;
211
474
  }> = ({ 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
475
  return (
223
476
  <>
224
- <Toolbar onInsertTag={handleInsertTag} />
477
+ <Toolbar />
225
478
  <PortableTextEditable
226
- className="min-h-[200px] p-4 focus:outline-none"
227
- style={{ backgroundColor: "white", color: "#333" }}
479
+ className="min-h-[200px] p-4 focus:outline-none text-[var(--kyro-text-primary)]"
228
480
  placeholder="Start typing..."
229
481
  readOnly={disabled}
482
+ renderStyle={renderStyle}
483
+ renderDecorator={renderDecorator}
484
+ renderBlock={renderBlock}
485
+ renderListItem={renderListItem}
486
+ renderAnnotation={renderAnnotation}
230
487
  />
231
488
  </>
232
489
  );
@@ -236,10 +493,18 @@ export const EditorClient: React.FC<EditorClientProps> = ({
236
493
  initialValue,
237
494
  onChange,
238
495
  disabled,
239
- theme,
240
496
  }) => {
241
- // Sanitize on mount — never pass raw/malformed data to EditorProvider
242
497
  const [value, setValue] = useState(() => sanitizeInitialValue(initialValue));
498
+ const prevInitialValueRef = useRef(initialValue);
499
+
500
+ useEffect(() => {
501
+ const sanitized = sanitizeInitialValue(initialValue);
502
+ const prevSanitized = sanitizeInitialValue(prevInitialValueRef.current);
503
+ if (JSON.stringify(sanitized) !== JSON.stringify(prevSanitized)) {
504
+ setValue(sanitized);
505
+ prevInitialValueRef.current = initialValue;
506
+ }
507
+ }, [initialValue]);
243
508
 
244
509
  const handleChange = useCallback(
245
510
  (newValue: any) => {
@@ -251,6 +516,7 @@ export const EditorClient: React.FC<EditorClientProps> = ({
251
516
 
252
517
  return (
253
518
  <EditorProvider
519
+ key={JSON.stringify(value)}
254
520
  initialConfig={{
255
521
  schemaDefinition: schemaDefinition as any,
256
522
  initialValue: value,