@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.
- package/README.md +28 -0
- package/admin/components/AdminCard.astro +25 -0
- package/admin/components/AiGenerateButton.tsx +102 -0
- package/admin/components/AssetsGrid.tsx +711 -0
- package/admin/components/BlockEditor.tsx +996 -0
- package/admin/components/CheckboxField.tsx +31 -0
- package/admin/components/DocumentActions.tsx +317 -0
- package/admin/components/DocumentLock.tsx +54 -0
- package/admin/components/DocumentsDataTable.tsx +804 -0
- package/admin/components/FieldControl.astro +397 -0
- package/admin/components/FocalPointSelector.tsx +100 -0
- package/admin/components/ImageBrowseDialog.tsx +176 -0
- package/admin/components/ImagePicker.tsx +149 -0
- package/admin/components/InternalLinkPicker.tsx +80 -0
- package/admin/components/LiveHeading.tsx +17 -0
- package/admin/components/MobileSidebar.tsx +29 -0
- package/admin/components/RelationField.tsx +204 -0
- package/admin/components/RichTextEditor.tsx +685 -0
- package/admin/components/SelectField.tsx +65 -0
- package/admin/components/SidebarUserMenu.tsx +99 -0
- package/admin/components/SlugField.tsx +77 -0
- package/admin/components/TaxonomySelect.tsx +52 -0
- package/admin/components/Toast.astro +40 -0
- package/admin/components/TreeItemsEditor.tsx +790 -0
- package/admin/components/TreeSelect.tsx +166 -0
- package/admin/components/UnsavedGuard.tsx +181 -0
- package/admin/components/tree-utils.ts +86 -0
- package/admin/components/ui/alert-dialog.tsx +92 -0
- package/admin/components/ui/badge.tsx +83 -0
- package/admin/components/ui/button.tsx +53 -0
- package/admin/components/ui/card.tsx +70 -0
- package/admin/components/ui/checkbox.tsx +28 -0
- package/admin/components/ui/collapsible.tsx +26 -0
- package/admin/components/ui/command.tsx +88 -0
- package/admin/components/ui/dialog.tsx +92 -0
- package/admin/components/ui/dropdown-menu.tsx +259 -0
- package/admin/components/ui/input.tsx +20 -0
- package/admin/components/ui/label.tsx +20 -0
- package/admin/components/ui/popover.tsx +42 -0
- package/admin/components/ui/select.tsx +165 -0
- package/admin/components/ui/separator.tsx +21 -0
- package/admin/components/ui/sheet.tsx +104 -0
- package/admin/components/ui/skeleton.tsx +7 -0
- package/admin/components/ui/table.tsx +74 -0
- package/admin/components/ui/textarea.tsx +18 -0
- package/admin/components/ui/tooltip.tsx +52 -0
- package/admin/layouts/AdminLayout.astro +340 -0
- package/admin/lib/utils.ts +19 -0
- package/dist/admin.js +92 -0
- package/dist/ai.js +67 -0
- package/dist/api.js +827 -0
- package/dist/assets.js +163 -0
- package/dist/auth.js +132 -0
- package/dist/blocks.js +110 -0
- package/dist/content.js +29 -0
- package/dist/create-admin.js +23 -0
- package/dist/define.js +36 -0
- package/dist/generator.js +370 -0
- package/dist/image.js +69 -0
- package/dist/index.js +16 -0
- package/dist/integration.js +256 -0
- package/dist/locks.js +37 -0
- package/dist/richtext.js +1 -0
- package/dist/runtime.js +26 -0
- package/dist/schema.js +13 -0
- package/dist/seed.js +84 -0
- package/dist/values.js +102 -0
- package/middleware/auth.ts +100 -0
- package/package.json +102 -0
- package/routes/api/cms/[collection]/[...path].ts +366 -0
- package/routes/api/cms/ai/alt-text.ts +25 -0
- package/routes/api/cms/ai/seo.ts +25 -0
- package/routes/api/cms/ai/translate.ts +31 -0
- package/routes/api/cms/assets/[id].ts +82 -0
- package/routes/api/cms/assets/folders.ts +81 -0
- package/routes/api/cms/assets/index.ts +23 -0
- package/routes/api/cms/assets/upload.ts +112 -0
- package/routes/api/cms/auth/invite.ts +166 -0
- package/routes/api/cms/auth/login.ts +124 -0
- package/routes/api/cms/auth/logout.ts +33 -0
- package/routes/api/cms/auth/setup.ts +77 -0
- package/routes/api/cms/cron/publish.ts +33 -0
- package/routes/api/cms/img/[...path].ts +24 -0
- package/routes/api/cms/locks/[...path].ts +37 -0
- package/routes/api/cms/preview/render.ts +36 -0
- package/routes/api/cms/references/[collection]/[id].ts +60 -0
- package/routes/pages/admin/[...path].astro +1104 -0
- package/routes/pages/admin/assets/[id].astro +183 -0
- package/routes/pages/admin/assets/index.astro +58 -0
- package/routes/pages/admin/invite.astro +116 -0
- package/routes/pages/admin/login.astro +57 -0
- package/routes/pages/admin/setup.astro +91 -0
- 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
|
+
}
|