@kidecms/core 0.1.0

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 (93) hide show
  1. package/README.md +28 -0
  2. package/admin/components/AdminCard.astro +25 -0
  3. package/admin/components/AiGenerateButton.tsx +102 -0
  4. package/admin/components/AssetsGrid.tsx +711 -0
  5. package/admin/components/BlockEditor.tsx +996 -0
  6. package/admin/components/CheckboxField.tsx +31 -0
  7. package/admin/components/DocumentActions.tsx +317 -0
  8. package/admin/components/DocumentLock.tsx +54 -0
  9. package/admin/components/DocumentsDataTable.tsx +804 -0
  10. package/admin/components/FieldControl.astro +397 -0
  11. package/admin/components/FocalPointSelector.tsx +100 -0
  12. package/admin/components/ImageBrowseDialog.tsx +176 -0
  13. package/admin/components/ImagePicker.tsx +149 -0
  14. package/admin/components/InternalLinkPicker.tsx +80 -0
  15. package/admin/components/LiveHeading.tsx +17 -0
  16. package/admin/components/MobileSidebar.tsx +29 -0
  17. package/admin/components/RelationField.tsx +204 -0
  18. package/admin/components/RichTextEditor.tsx +685 -0
  19. package/admin/components/SelectField.tsx +65 -0
  20. package/admin/components/SidebarUserMenu.tsx +99 -0
  21. package/admin/components/SlugField.tsx +77 -0
  22. package/admin/components/TaxonomySelect.tsx +52 -0
  23. package/admin/components/Toast.astro +40 -0
  24. package/admin/components/TreeItemsEditor.tsx +790 -0
  25. package/admin/components/TreeSelect.tsx +166 -0
  26. package/admin/components/UnsavedGuard.tsx +181 -0
  27. package/admin/components/tree-utils.ts +86 -0
  28. package/admin/components/ui/alert-dialog.tsx +92 -0
  29. package/admin/components/ui/badge.tsx +83 -0
  30. package/admin/components/ui/button.tsx +53 -0
  31. package/admin/components/ui/card.tsx +70 -0
  32. package/admin/components/ui/checkbox.tsx +28 -0
  33. package/admin/components/ui/collapsible.tsx +26 -0
  34. package/admin/components/ui/command.tsx +88 -0
  35. package/admin/components/ui/dialog.tsx +92 -0
  36. package/admin/components/ui/dropdown-menu.tsx +259 -0
  37. package/admin/components/ui/input.tsx +20 -0
  38. package/admin/components/ui/label.tsx +20 -0
  39. package/admin/components/ui/popover.tsx +42 -0
  40. package/admin/components/ui/select.tsx +165 -0
  41. package/admin/components/ui/separator.tsx +21 -0
  42. package/admin/components/ui/sheet.tsx +104 -0
  43. package/admin/components/ui/skeleton.tsx +7 -0
  44. package/admin/components/ui/table.tsx +74 -0
  45. package/admin/components/ui/textarea.tsx +18 -0
  46. package/admin/components/ui/tooltip.tsx +52 -0
  47. package/admin/layouts/AdminLayout.astro +340 -0
  48. package/admin/lib/utils.ts +19 -0
  49. package/dist/admin.js +92 -0
  50. package/dist/ai.js +67 -0
  51. package/dist/api.js +827 -0
  52. package/dist/assets.js +163 -0
  53. package/dist/auth.js +132 -0
  54. package/dist/blocks.js +110 -0
  55. package/dist/content.js +29 -0
  56. package/dist/create-admin.js +23 -0
  57. package/dist/define.js +36 -0
  58. package/dist/generator.js +370 -0
  59. package/dist/image.js +69 -0
  60. package/dist/index.js +16 -0
  61. package/dist/integration.js +256 -0
  62. package/dist/locks.js +37 -0
  63. package/dist/richtext.js +1 -0
  64. package/dist/runtime.js +26 -0
  65. package/dist/schema.js +13 -0
  66. package/dist/seed.js +84 -0
  67. package/dist/values.js +102 -0
  68. package/middleware/auth.ts +100 -0
  69. package/package.json +102 -0
  70. package/routes/api/cms/[collection]/[...path].ts +366 -0
  71. package/routes/api/cms/ai/alt-text.ts +25 -0
  72. package/routes/api/cms/ai/seo.ts +25 -0
  73. package/routes/api/cms/ai/translate.ts +31 -0
  74. package/routes/api/cms/assets/[id].ts +82 -0
  75. package/routes/api/cms/assets/folders.ts +81 -0
  76. package/routes/api/cms/assets/index.ts +23 -0
  77. package/routes/api/cms/assets/upload.ts +112 -0
  78. package/routes/api/cms/auth/invite.ts +166 -0
  79. package/routes/api/cms/auth/login.ts +124 -0
  80. package/routes/api/cms/auth/logout.ts +33 -0
  81. package/routes/api/cms/auth/setup.ts +77 -0
  82. package/routes/api/cms/cron/publish.ts +33 -0
  83. package/routes/api/cms/img/[...path].ts +24 -0
  84. package/routes/api/cms/locks/[...path].ts +37 -0
  85. package/routes/api/cms/preview/render.ts +36 -0
  86. package/routes/api/cms/references/[collection]/[id].ts +60 -0
  87. package/routes/pages/admin/[...path].astro +1104 -0
  88. package/routes/pages/admin/assets/[id].astro +183 -0
  89. package/routes/pages/admin/assets/index.astro +58 -0
  90. package/routes/pages/admin/invite.astro +116 -0
  91. package/routes/pages/admin/login.astro +57 -0
  92. package/routes/pages/admin/setup.astro +91 -0
  93. package/virtual.d.ts +61 -0
@@ -0,0 +1,685 @@
1
+ import { useEditor, EditorContent } from "@tiptap/react";
2
+ import StarterKit from "@tiptap/starter-kit";
3
+ import Image from "@tiptap/extension-image";
4
+ import Link from "@tiptap/extension-link";
5
+ import { Markdown } from "@tiptap/markdown";
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ import {
8
+ Bold,
9
+ Check,
10
+ ChevronsUpDown,
11
+ Italic,
12
+ Heading2,
13
+ Heading3,
14
+ List,
15
+ ListOrdered,
16
+ Quote,
17
+ ImageIcon,
18
+ Link as LinkIcon,
19
+ Undo,
20
+ Redo,
21
+ } from "lucide-react";
22
+ import { Button } from "./ui/button";
23
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./ui/command";
24
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "./ui/dialog";
25
+ import { Input } from "./ui/input";
26
+ import { Label } from "./ui/label";
27
+ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
28
+ import { cn } from "../lib/utils";
29
+ import ImageBrowseDialog from "./ImageBrowseDialog";
30
+
31
+ // -----------------------------------------------
32
+ // CMS AST ↔ Tiptap JSON conversion
33
+ // -----------------------------------------------
34
+
35
+ type CmsNode = {
36
+ type: string;
37
+ value?: string;
38
+ level?: number;
39
+ ordered?: boolean;
40
+ bold?: boolean;
41
+ italic?: boolean;
42
+ children?: CmsNode[];
43
+ [key: string]: unknown;
44
+ };
45
+
46
+ type CmsDocument = {
47
+ type: "root";
48
+ children: CmsNode[];
49
+ };
50
+
51
+ const cmsNodeToTiptap = (node: CmsNode): any => {
52
+ if (node.type === "text") {
53
+ const marks: any[] = [];
54
+ if (node.bold) marks.push({ type: "bold" });
55
+ if (node.italic) marks.push({ type: "italic" });
56
+ if (node.href) marks.push({ type: "link", attrs: { href: node.href, target: node.target ?? null } });
57
+ return { type: "text", text: node.value ?? "", ...(marks.length > 0 ? { marks } : {}) };
58
+ }
59
+ if (node.type === "paragraph") {
60
+ const content = (node.children ?? []).map(cmsNodeToTiptap).filter(Boolean);
61
+ return { type: "paragraph", ...(content.length > 0 ? { content } : {}) };
62
+ }
63
+ if (node.type === "heading") {
64
+ const content = (node.children ?? []).map(cmsNodeToTiptap).filter(Boolean);
65
+ return {
66
+ type: "heading",
67
+ attrs: { level: node.level ?? 2 },
68
+ ...(content.length > 0 ? { content } : {}),
69
+ };
70
+ }
71
+ if (node.type === "list") {
72
+ const content = (node.children ?? []).map(cmsNodeToTiptap).filter(Boolean);
73
+ return {
74
+ type: node.ordered ? "orderedList" : "bulletList",
75
+ ...(content.length > 0 ? { content } : {}),
76
+ };
77
+ }
78
+ if (node.type === "list-item") {
79
+ const content = (node.children ?? []).map(cmsNodeToTiptap).filter(Boolean);
80
+ // Tiptap expects list items to contain paragraphs
81
+ const wrapped = content.map((c: any) => (c.type === "paragraph" ? c : { type: "paragraph", content: [c] }));
82
+ return { type: "listItem", ...(wrapped.length > 0 ? { content: wrapped } : {}) };
83
+ }
84
+ if (node.type === "quote") {
85
+ const content = (node.children ?? []).map(cmsNodeToTiptap).filter(Boolean);
86
+ return { type: "blockquote", ...(content.length > 0 ? { content } : {}) };
87
+ }
88
+ if (node.type === "image") {
89
+ return { type: "image", attrs: { src: node.src ?? "", alt: node.alt ?? "", title: node.title ?? "" } };
90
+ }
91
+ return null;
92
+ };
93
+
94
+ const cmsToTiptap = (doc: CmsDocument | null | undefined): any => {
95
+ if (!doc || doc.type !== "root") {
96
+ return { type: "doc", content: [{ type: "paragraph" }] };
97
+ }
98
+ const content = doc.children.map(cmsNodeToTiptap).filter(Boolean);
99
+ return { type: "doc", content: content.length > 0 ? content : [{ type: "paragraph" }] };
100
+ };
101
+
102
+ const tiptapNodeToCms = (node: any): CmsNode | null => {
103
+ if (node.type === "text") {
104
+ const result: CmsNode = { type: "text", value: node.text ?? "" };
105
+ if (node.marks) {
106
+ for (const mark of node.marks) {
107
+ if (mark.type === "bold") result.bold = true;
108
+ if (mark.type === "italic") result.italic = true;
109
+ if (mark.type === "link") {
110
+ result.href = mark.attrs?.href ?? "";
111
+ if (mark.attrs?.target) result.target = mark.attrs.target;
112
+ }
113
+ }
114
+ }
115
+ return result;
116
+ }
117
+ if (node.type === "paragraph") {
118
+ return {
119
+ type: "paragraph",
120
+ children: (node.content ?? []).map(tiptapNodeToCms).filter(Boolean),
121
+ };
122
+ }
123
+ if (node.type === "heading") {
124
+ return {
125
+ type: "heading",
126
+ level: node.attrs?.level ?? 2,
127
+ children: (node.content ?? []).map(tiptapNodeToCms).filter(Boolean),
128
+ };
129
+ }
130
+ if (node.type === "bulletList" || node.type === "orderedList") {
131
+ return {
132
+ type: "list",
133
+ ordered: node.type === "orderedList",
134
+ children: (node.content ?? []).map(tiptapNodeToCms).filter(Boolean),
135
+ };
136
+ }
137
+ if (node.type === "listItem") {
138
+ return {
139
+ type: "list-item",
140
+ children: (node.content ?? []).map(tiptapNodeToCms).filter(Boolean),
141
+ };
142
+ }
143
+ if (node.type === "blockquote") {
144
+ return {
145
+ type: "quote",
146
+ children: (node.content ?? []).map(tiptapNodeToCms).filter(Boolean),
147
+ };
148
+ }
149
+ if (node.type === "image") {
150
+ return { type: "image", src: node.attrs?.src ?? "", alt: node.attrs?.alt ?? "", title: node.attrs?.title ?? "" };
151
+ }
152
+ return null;
153
+ };
154
+
155
+ const tiptapToCms = (json: any): CmsDocument => ({
156
+ type: "root",
157
+ children: (json.content ?? []).map(tiptapNodeToCms).filter(Boolean),
158
+ });
159
+
160
+ // -----------------------------------------------
161
+ // Toolbar button
162
+ // -----------------------------------------------
163
+
164
+ const ToolbarButton = ({
165
+ onClick,
166
+ active,
167
+ disabled,
168
+ children,
169
+ title,
170
+ }: {
171
+ onClick: () => void;
172
+ active?: boolean;
173
+ disabled?: boolean;
174
+ children: React.ReactNode;
175
+ title: string;
176
+ }) => (
177
+ <button
178
+ type="button"
179
+ onClick={onClick}
180
+ disabled={disabled}
181
+ title={title}
182
+ className={cn(
183
+ "focus-visible:ring-ring/50 focus-visible:border-ring inline-flex size-8 items-center justify-center rounded-md transition-colors outline-none focus-visible:ring-2 disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground",
184
+ active ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
185
+ )}
186
+ >
187
+ {children}
188
+ </button>
189
+ );
190
+
191
+ // -----------------------------------------------
192
+ // Link dialog
193
+ // -----------------------------------------------
194
+
195
+ function LinkDialog({
196
+ open,
197
+ onOpenChange,
198
+ linkType,
199
+ onLinkTypeChange,
200
+ linkUrl,
201
+ onLinkUrlChange,
202
+ linkGroups,
203
+ isEditing,
204
+ onApply,
205
+ onRemove,
206
+ }: {
207
+ open: boolean;
208
+ onOpenChange: (open: boolean) => void;
209
+ linkType: "internal" | "external";
210
+ onLinkTypeChange: (type: "internal" | "external") => void;
211
+ linkUrl: string;
212
+ onLinkUrlChange: (url: string) => void;
213
+ linkGroups: Array<{ label: string; items: Array<{ label: string; href: string }> }>;
214
+ isEditing: boolean;
215
+ onApply: () => void;
216
+ onRemove: () => void;
217
+ }) {
218
+ const [comboOpen, setComboOpen] = useState(false);
219
+ const selectedLabel = (() => {
220
+ for (const group of linkGroups) {
221
+ const found = group.items.find((item) => item.href === linkUrl);
222
+ if (found) return found.label;
223
+ }
224
+ return "";
225
+ })();
226
+
227
+ return (
228
+ <Dialog open={open} onOpenChange={onOpenChange}>
229
+ <DialogContent>
230
+ <DialogHeader>
231
+ <DialogTitle>{isEditing ? "Edit link" : "Insert link"}</DialogTitle>
232
+ </DialogHeader>
233
+ <div className="grid gap-4 py-2">
234
+ <div className="flex gap-4">
235
+ <label className="flex items-center gap-2 text-sm">
236
+ <input
237
+ type="radio"
238
+ name="link-type"
239
+ checked={linkType === "internal"}
240
+ onChange={() => {
241
+ onLinkTypeChange("internal");
242
+ onLinkUrlChange("");
243
+ }}
244
+ className="size-4"
245
+ />
246
+ Internal
247
+ </label>
248
+ <label className="flex items-center gap-2 text-sm">
249
+ <input
250
+ type="radio"
251
+ name="link-type"
252
+ checked={linkType === "external"}
253
+ onChange={() => {
254
+ onLinkTypeChange("external");
255
+ onLinkUrlChange("");
256
+ }}
257
+ className="size-4"
258
+ />
259
+ External
260
+ </label>
261
+ </div>
262
+
263
+ {linkType === "external" ? (
264
+ <div className="grid gap-2">
265
+ <Label htmlFor="link-url">URL</Label>
266
+ <Input
267
+ id="link-url"
268
+ value={linkUrl}
269
+ onChange={(e) => onLinkUrlChange(e.target.value)}
270
+ placeholder="https://example.com"
271
+ autoFocus
272
+ onKeyDown={(e) => {
273
+ if (e.key === "Enter") {
274
+ e.preventDefault();
275
+ onApply();
276
+ }
277
+ }}
278
+ />
279
+ </div>
280
+ ) : (
281
+ <div className="grid gap-2">
282
+ <Label>Page</Label>
283
+ <Popover open={comboOpen} onOpenChange={setComboOpen}>
284
+ <PopoverTrigger asChild>
285
+ <Button
286
+ variant="outline"
287
+ role="combobox"
288
+ aria-expanded={comboOpen}
289
+ size="lg"
290
+ className="border-input bg-muted/30 hover:bg-muted dark:bg-input/30 dark:hover:bg-input/50 w-full justify-between text-base font-normal"
291
+ >
292
+ <span className={cn("truncate", !selectedLabel && "text-muted-foreground")}>
293
+ {selectedLabel || "Search documents..."}
294
+ </span>
295
+ <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
296
+ </Button>
297
+ </PopoverTrigger>
298
+ <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
299
+ <Command>
300
+ <CommandInput placeholder="Search documents..." />
301
+ <CommandList>
302
+ <CommandEmpty>No documents found.</CommandEmpty>
303
+ {linkGroups.map((group) => (
304
+ <CommandGroup key={group.label} heading={group.label}>
305
+ {group.items.map((item) => (
306
+ <CommandItem
307
+ key={item.href}
308
+ value={`${item.label} ${item.href}`}
309
+ onSelect={() => {
310
+ onLinkUrlChange(item.href === linkUrl ? "" : item.href);
311
+ setComboOpen(false);
312
+ }}
313
+ >
314
+ <Check
315
+ className={cn("ml-1 size-4", linkUrl === item.href ? "opacity-100" : "opacity-0")}
316
+ />
317
+ {item.label}
318
+ </CommandItem>
319
+ ))}
320
+ </CommandGroup>
321
+ ))}
322
+ </CommandList>
323
+ </Command>
324
+ </PopoverContent>
325
+ </Popover>
326
+ </div>
327
+ )}
328
+ </div>
329
+ <DialogFooter>
330
+ {isEditing && (
331
+ <Button variant="ghost" className="mr-auto" onClick={onRemove}>
332
+ Remove link
333
+ </Button>
334
+ )}
335
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
336
+ Cancel
337
+ </Button>
338
+ <Button onClick={onApply} disabled={!linkUrl}>
339
+ Apply
340
+ </Button>
341
+ </DialogFooter>
342
+ </DialogContent>
343
+ </Dialog>
344
+ );
345
+ }
346
+
347
+ // -----------------------------------------------
348
+ // Editor component
349
+ // -----------------------------------------------
350
+
351
+ type Props = {
352
+ name: string;
353
+ initialValue?: string;
354
+ rows?: number;
355
+ onChange?: (value: string) => void;
356
+ };
357
+
358
+ export default function RichTextEditor({ name, initialValue, rows = 10, onChange }: Props) {
359
+ const hiddenRef = useRef<HTMLInputElement>(null);
360
+ const previewChannelRef = useRef<BroadcastChannel | null>(null);
361
+ const [imageBrowseOpen, setImageBrowseOpen] = useState(false);
362
+ const [linkDialogOpen, setLinkDialogOpen] = useState(false);
363
+ const [linkType, setLinkType] = useState<"internal" | "external">("internal");
364
+ const [linkUrl, setLinkUrl] = useState("");
365
+ const [linkGroups, setLinkGroups] = useState<Array<{ label: string; items: Array<{ label: string; href: string }> }>>(
366
+ [],
367
+ );
368
+
369
+ const [markdownMode, setMarkdownMode] = useState(false);
370
+ const [markdownText, setMarkdownText] = useState("");
371
+
372
+ const [cmsJson, setCmsJson] = useState<string>(() => {
373
+ if (!initialValue) return JSON.stringify({ type: "root", children: [] });
374
+ try {
375
+ const parsed = JSON.parse(initialValue);
376
+ if (parsed?.type === "root") return initialValue;
377
+ } catch {}
378
+ return JSON.stringify({ type: "root", children: [] });
379
+ });
380
+
381
+ // Counter to force re-renders on selection/transaction changes
382
+ const [, setTick] = useState(0);
383
+ const forceTick = useCallback(() => setTick((t) => t + 1), []);
384
+
385
+ const parsedInitial = (() => {
386
+ try {
387
+ return JSON.parse(cmsJson);
388
+ } catch {
389
+ return { type: "root", children: [] };
390
+ }
391
+ })();
392
+
393
+ // Notify the form of value changes so UnsavedGuard detects them
394
+ const prevCmsJsonRef = useRef(cmsJson);
395
+ useEffect(() => {
396
+ if (prevCmsJsonRef.current !== cmsJson) {
397
+ prevCmsJsonRef.current = cmsJson;
398
+ hiddenRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
399
+ }
400
+ }, [cmsJson]);
401
+
402
+ const editor = useEditor({
403
+ immediatelyRender: false,
404
+ extensions: [
405
+ StarterKit,
406
+ Image,
407
+ Markdown,
408
+ Link.configure({
409
+ openOnClick: false,
410
+ autolink: false,
411
+ linkOnPaste: false,
412
+ HTMLAttributes: { class: "text-primary underline cursor-text pointer-events-none" },
413
+ }),
414
+ ],
415
+ content: cmsToTiptap(parsedInitial),
416
+ onUpdate: ({ editor }) => {
417
+ const tiptapJson = editor.getJSON();
418
+ const cmsDoc = tiptapToCms(tiptapJson);
419
+ const json = JSON.stringify(cmsDoc);
420
+ setCmsJson(json);
421
+ onChange?.(json);
422
+ // Live preview: broadcast for server-side rendering
423
+ if (!previewChannelRef.current) previewChannelRef.current = new BroadcastChannel("cms-preview");
424
+ previewChannelRef.current.postMessage({ field: name, value: json, render: "richText" });
425
+ },
426
+ onSelectionUpdate: forceTick,
427
+ onTransaction: forceTick,
428
+ editorProps: {
429
+ attributes: {
430
+ class: "prose prose-sm max-w-none text-base focus:outline-none",
431
+ style: `min-height: ${rows * 1.5}rem; padding: 0.625rem 0.75rem`,
432
+ },
433
+ handleDOMEvents: {
434
+ mousedown(_view, event) {
435
+ const target = event.target as HTMLElement;
436
+ if (target.tagName === "A" || target.closest("a")) {
437
+ event.preventDefault();
438
+ }
439
+ },
440
+ click(_view, event) {
441
+ const target = event.target as HTMLElement;
442
+ if (target.tagName === "A" || target.closest("a")) {
443
+ event.preventDefault();
444
+ event.stopPropagation();
445
+ return true;
446
+ }
447
+ },
448
+ },
449
+ },
450
+ });
451
+
452
+ // Listen for external content updates (e.g. AI translate)
453
+ useEffect(() => {
454
+ const hidden = hiddenRef.current;
455
+ if (!hidden) return;
456
+ const handler = (e: Event) => {
457
+ const detail = (e as CustomEvent).detail;
458
+ if (typeof detail !== "string") return;
459
+ try {
460
+ const parsed = JSON.parse(detail);
461
+ if (parsed?.type === "root") {
462
+ setCmsJson(detail);
463
+ if (editor) {
464
+ editor.commands.setContent(cmsToTiptap(parsed));
465
+ }
466
+ }
467
+ } catch {}
468
+ };
469
+ hidden.addEventListener("cms:set-value", handler);
470
+ return () => hidden.removeEventListener("cms:set-value", handler);
471
+ });
472
+
473
+ const minHeight = `${rows * 1.5}rem`;
474
+
475
+ return (
476
+ <div className="border-input hover:border-foreground/20 focus-within:border-ring focus-within:ring-ring/50 overflow-hidden rounded-lg border transition-colors focus-within:ring-3">
477
+ <input ref={hiddenRef} type="hidden" name={name} value={cmsJson} />
478
+
479
+ {/* Toolbar */}
480
+ <div className="bg-muted/40 dark:bg-input/30 flex flex-wrap items-center gap-0.5 border-b px-2 py-1.5">
481
+ <ToolbarButton
482
+ onClick={() => editor?.chain().focus().toggleBold().run()}
483
+ active={editor?.isActive("bold")}
484
+ disabled={!editor || markdownMode}
485
+ title="Bold"
486
+ >
487
+ <Bold className="size-4" />
488
+ </ToolbarButton>
489
+ <ToolbarButton
490
+ onClick={() => editor?.chain().focus().toggleItalic().run()}
491
+ active={editor?.isActive("italic")}
492
+ disabled={!editor || markdownMode}
493
+ title="Italic"
494
+ >
495
+ <Italic className="size-4" />
496
+ </ToolbarButton>
497
+
498
+ <div className="bg-border mx-1 h-5 w-px" />
499
+
500
+ <ToolbarButton
501
+ onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
502
+ active={editor?.isActive("heading", { level: 2 })}
503
+ disabled={!editor || markdownMode}
504
+ title="Heading 2"
505
+ >
506
+ <Heading2 className="size-4" />
507
+ </ToolbarButton>
508
+ <ToolbarButton
509
+ onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
510
+ active={editor?.isActive("heading", { level: 3 })}
511
+ disabled={!editor || markdownMode}
512
+ title="Heading 3"
513
+ >
514
+ <Heading3 className="size-4" />
515
+ </ToolbarButton>
516
+
517
+ <div className="bg-border mx-1 h-5 w-px" />
518
+
519
+ <ToolbarButton
520
+ onClick={() => editor?.chain().focus().toggleBulletList().run()}
521
+ active={editor?.isActive("bulletList")}
522
+ disabled={!editor || markdownMode}
523
+ title="Bullet list"
524
+ >
525
+ <List className="size-4" />
526
+ </ToolbarButton>
527
+ <ToolbarButton
528
+ onClick={() => editor?.chain().focus().toggleOrderedList().run()}
529
+ active={editor?.isActive("orderedList")}
530
+ disabled={!editor || markdownMode}
531
+ title="Ordered list"
532
+ >
533
+ <ListOrdered className="size-4" />
534
+ </ToolbarButton>
535
+ <ToolbarButton
536
+ onClick={() => editor?.chain().focus().toggleBlockquote().run()}
537
+ active={editor?.isActive("blockquote")}
538
+ disabled={!editor || markdownMode}
539
+ title="Blockquote"
540
+ >
541
+ <Quote className="size-4" />
542
+ </ToolbarButton>
543
+
544
+ <ToolbarButton
545
+ onClick={() => {
546
+ const href = editor?.getAttributes("link").href ?? "";
547
+ setLinkUrl(href);
548
+ const isExternal = href.startsWith("http://") || href.startsWith("https://");
549
+ setLinkType(href && !isExternal ? "internal" : href ? "external" : "internal");
550
+ // Fetch internal pages for the picker
551
+ if (linkGroups.length === 0) {
552
+ Promise.all([
553
+ fetch("/api/cms/pages?status=published&limit=200").then((r) => (r.ok ? r.json() : { docs: [] })),
554
+ fetch("/api/cms/posts?status=published&limit=200").then((r) => (r.ok ? r.json() : { docs: [] })),
555
+ ]).then(([pagesRes, postsRes]) => {
556
+ const groups: typeof linkGroups = [];
557
+ if (pagesRes.docs?.length) {
558
+ groups.push({
559
+ label: "Pages",
560
+ items: pagesRes.docs.map((p: Record<string, unknown>) => ({
561
+ label: String(p.title ?? p.slug),
562
+ href: `/${p.slug}`,
563
+ })),
564
+ });
565
+ }
566
+ if (postsRes.docs?.length) {
567
+ groups.push({
568
+ label: "Posts",
569
+ items: postsRes.docs.map((p: Record<string, unknown>) => ({
570
+ label: String(p.title ?? p.slug),
571
+ href: `/blog/${p.slug}`,
572
+ })),
573
+ });
574
+ }
575
+ setLinkGroups(groups);
576
+ });
577
+ }
578
+ setLinkDialogOpen(true);
579
+ }}
580
+ active={editor?.isActive("link")}
581
+ disabled={!editor || markdownMode}
582
+ title="Link"
583
+ >
584
+ <LinkIcon className="size-4" />
585
+ </ToolbarButton>
586
+
587
+ <div className="bg-border mx-1 h-5 w-px" />
588
+
589
+ <ToolbarButton onClick={() => setImageBrowseOpen(true)} disabled={!editor || markdownMode} title="Insert image">
590
+ <ImageIcon className="size-4" />
591
+ </ToolbarButton>
592
+ <div className="bg-border mx-1 h-5 w-px" />
593
+
594
+ <ToolbarButton
595
+ onClick={() => editor?.chain().focus().undo().run()}
596
+ disabled={!editor?.can().undo() || markdownMode}
597
+ title="Undo"
598
+ >
599
+ <Undo className="size-4" />
600
+ </ToolbarButton>
601
+ <ToolbarButton
602
+ onClick={() => editor?.chain().focus().redo().run()}
603
+ disabled={!editor?.can().redo() || markdownMode}
604
+ title="Redo"
605
+ >
606
+ <Redo className="size-4" />
607
+ </ToolbarButton>
608
+
609
+ <div className="ml-auto" />
610
+
611
+ <ToolbarButton
612
+ onClick={() => {
613
+ if (!editor) return;
614
+ if (!markdownMode) {
615
+ setMarkdownText(editor.getMarkdown());
616
+ setMarkdownMode(true);
617
+ } else {
618
+ editor.commands.setContent(markdownText, { contentType: "markdown" });
619
+ setMarkdownMode(false);
620
+ }
621
+ }}
622
+ active={markdownMode}
623
+ disabled={!editor}
624
+ title={markdownMode ? "Switch to editor" : "Switch to Markdown"}
625
+ >
626
+ <span className="text-xs font-semibold leading-none">MD</span>
627
+ </ToolbarButton>
628
+ </div>
629
+
630
+ {/* Editor area */}
631
+ {markdownMode ? (
632
+ <textarea
633
+ className="w-full resize-none bg-transparent px-3 py-2.5 font-mono text-sm focus:outline-none"
634
+ style={{ minHeight }}
635
+ value={markdownText}
636
+ onChange={(e) => {
637
+ setMarkdownText(e.target.value);
638
+ // Update CMS JSON from markdown so the form stays in sync
639
+ if (editor) {
640
+ editor.commands.setContent(e.target.value, { contentType: "markdown" });
641
+ }
642
+ }}
643
+ />
644
+ ) : editor ? (
645
+ <EditorContent editor={editor} />
646
+ ) : (
647
+ <div className="prose prose-sm max-w-none" style={{ minHeight, padding: "0.625rem 0.75rem" }} />
648
+ )}
649
+
650
+ <ImageBrowseDialog
651
+ open={imageBrowseOpen}
652
+ onOpenChange={setImageBrowseOpen}
653
+ onSelect={(asset) => {
654
+ editor?.chain().focus().setImage({ src: asset.url, alt: asset.filename }).run();
655
+ }}
656
+ />
657
+
658
+ <LinkDialog
659
+ open={linkDialogOpen}
660
+ onOpenChange={setLinkDialogOpen}
661
+ linkType={linkType}
662
+ onLinkTypeChange={setLinkType}
663
+ linkUrl={linkUrl}
664
+ onLinkUrlChange={setLinkUrl}
665
+ linkGroups={linkGroups}
666
+ isEditing={!!editor?.isActive("link")}
667
+ onApply={() => {
668
+ if (linkUrl) {
669
+ const isExternal = linkUrl.startsWith("http://") || linkUrl.startsWith("https://");
670
+ editor
671
+ ?.chain()
672
+ .focus()
673
+ .setLink({ href: linkUrl, target: isExternal ? "_blank" : null })
674
+ .run();
675
+ }
676
+ setLinkDialogOpen(false);
677
+ }}
678
+ onRemove={() => {
679
+ editor?.chain().focus().unsetLink().run();
680
+ setLinkDialogOpen(false);
681
+ }}
682
+ />
683
+ </div>
684
+ );
685
+ }