@bygghammar/cms-platform 0.4.0 → 0.6.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.
@@ -0,0 +1,633 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { EditorContent, useEditor, type Editor } from "@tiptap/react";
3
+ import StarterKit from "@tiptap/starter-kit";
4
+ import Image from "@tiptap/extension-image";
5
+ import TextAlign from "@tiptap/extension-text-align";
6
+ import Highlight from "@tiptap/extension-highlight";
7
+ import Subscript from "@tiptap/extension-subscript";
8
+ import Superscript from "@tiptap/extension-superscript";
9
+ import { Placeholder } from "@tiptap/extensions";
10
+ import { Color, FontFamily, FontSize, LineHeight, TextStyle } from "@tiptap/extension-text-style";
11
+ import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
12
+ import {
13
+ AlignCenter,
14
+ AlignJustify,
15
+ AlignLeft,
16
+ AlignRight,
17
+ Bold,
18
+ Code,
19
+ Code2,
20
+ Eraser,
21
+ Image as ImageIcon,
22
+ Italic,
23
+ Link as LinkIcon,
24
+ List,
25
+ ListOrdered,
26
+ Minus,
27
+ Quote,
28
+ Redo2,
29
+ Strikethrough,
30
+ Subscript as SubIcon,
31
+ Superscript as SupIcon,
32
+ Table as TableIcon,
33
+ Underline as UnderlineIcon,
34
+ Undo2,
35
+ Unlink,
36
+ } from "lucide-react";
37
+ import { MediaPickerDialog } from "./inspectors/fields";
38
+ import { sanitizeHtml } from "../html/sanitize-html";
39
+
40
+ const FONTS: { label: string; value: string }[] = [
41
+ { label: "Theme (default)", value: "" },
42
+ { label: "Serif (display)", value: "var(--font-serif)" },
43
+ { label: "Sans (body)", value: "var(--font-sans)" },
44
+ { label: "Georgia", value: "Georgia, serif" },
45
+ { label: "Times New Roman", value: "'Times New Roman', serif" },
46
+ { label: "Helvetica", value: "Helvetica, Arial, sans-serif" },
47
+ { label: "Arial", value: "Arial, sans-serif" },
48
+ { label: "Courier", value: "'Courier New', monospace" },
49
+ ];
50
+
51
+ const SIZES = ["", "12px", "14px", "16px", "18px", "20px", "24px", "30px", "36px", "48px", "60px"];
52
+
53
+ const SWATCHES = [
54
+ "#0f1720",
55
+ "#4b5563",
56
+ "#9ca3af",
57
+ "#ffffff",
58
+ "#2ac4ea",
59
+ "#d8f2fd",
60
+ "#afafaf",
61
+ "#0ea5e9",
62
+ "#16a34a",
63
+ "#eab308",
64
+ "#f97316",
65
+ "#dc2626",
66
+ ];
67
+
68
+ function Btn({
69
+ on,
70
+ active,
71
+ title,
72
+ disabled,
73
+ children,
74
+ }: {
75
+ on: () => void;
76
+ active?: boolean;
77
+ title: string;
78
+ disabled?: boolean;
79
+ children: React.ReactNode;
80
+ }) {
81
+ return (
82
+ <button
83
+ type="button"
84
+ title={title}
85
+ aria-label={title}
86
+ aria-pressed={active}
87
+ disabled={disabled}
88
+ onMouseDown={(e) => e.preventDefault()}
89
+ onClick={on}
90
+ className={[
91
+ "flex size-7 items-center justify-center border text-ink transition-colors disabled:opacity-35",
92
+ active ? "border-sea bg-sea/15" : "border-transparent hover:border-ink/20",
93
+ ].join(" ")}
94
+ >
95
+ {children}
96
+ </button>
97
+ );
98
+ }
99
+
100
+ function Divider() {
101
+ return <span className="mx-1 h-5 w-px bg-ink/10" />;
102
+ }
103
+
104
+ function ColorControl({
105
+ label,
106
+ value,
107
+ onPick,
108
+ onClear,
109
+ }: {
110
+ label: string;
111
+ value: string;
112
+ onPick: (color: string) => void;
113
+ onClear: () => void;
114
+ }) {
115
+ const [open, setOpen] = useState(false);
116
+ return (
117
+ <div className="relative">
118
+ <button
119
+ type="button"
120
+ title={label}
121
+ onMouseDown={(e) => e.preventDefault()}
122
+ onClick={() => setOpen((o) => !o)}
123
+ className="flex h-7 items-center gap-1 border border-transparent px-1.5 text-[0.65rem] uppercase tracking-widest text-ink hover:border-ink/20"
124
+ >
125
+ {label}
126
+ <span
127
+ className="size-3 border border-ink/25"
128
+ style={{ background: value || "transparent" }}
129
+ />
130
+ </button>
131
+ {open ? (
132
+ <>
133
+ <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
134
+ <div className="absolute left-0 top-8 z-50 w-44 border border-ink/15 bg-white p-2 shadow-lg">
135
+ <div className="grid grid-cols-6 gap-1">
136
+ {SWATCHES.map((c) => (
137
+ <button
138
+ key={c}
139
+ type="button"
140
+ onMouseDown={(e) => e.preventDefault()}
141
+ onClick={() => {
142
+ onPick(c);
143
+ setOpen(false);
144
+ }}
145
+ className="size-5 border border-ink/15"
146
+ style={{ background: c }}
147
+ title={c}
148
+ />
149
+ ))}
150
+ </div>
151
+ <div className="mt-2 flex items-center gap-2">
152
+ <input
153
+ type="color"
154
+ value={value || "#000000"}
155
+ onChange={(e) => onPick(e.target.value)}
156
+ className="h-6 w-10 border border-ink/15 bg-white"
157
+ />
158
+ <button
159
+ type="button"
160
+ onMouseDown={(e) => e.preventDefault()}
161
+ onClick={() => {
162
+ onClear();
163
+ setOpen(false);
164
+ }}
165
+ className="text-[0.65rem] uppercase tracking-widest text-ink/60 hover:text-ink"
166
+ >
167
+ Clear
168
+ </button>
169
+ </div>
170
+ </div>
171
+ </>
172
+ ) : null}
173
+ </div>
174
+ );
175
+ }
176
+
177
+ function Toolbar({ editor }: { editor: Editor }) {
178
+ const [pickImage, setPickImage] = useState(false);
179
+
180
+ const setLink = () => {
181
+ const previous = (editor.getAttributes("link").href as string) ?? "";
182
+ const url = window.prompt("Link URL (https://…, /page, mailto:, tel:)", previous);
183
+ if (url === null) return;
184
+ if (!url.trim()) {
185
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
186
+ return;
187
+ }
188
+ editor.chain().focus().extendMarkRange("link").setLink({ href: url.trim() }).run();
189
+ };
190
+
191
+ const headingValue = (() => {
192
+ for (const level of [1, 2, 3, 4, 5, 6] as const) {
193
+ if (editor.isActive("heading", { level })) return `h${level}`;
194
+ }
195
+ return editor.isActive("codeBlock") ? "code" : "p";
196
+ })();
197
+
198
+ return (
199
+ <div className="sticky top-0 z-20 flex flex-wrap items-center gap-0.5 border-b border-ink/10 bg-white/95 p-1.5 backdrop-blur">
200
+ <Btn
201
+ on={() => editor.chain().focus().undo().run()}
202
+ title="Undo"
203
+ disabled={!editor.can().undo()}
204
+ >
205
+ <Undo2 className="size-3.5" />
206
+ </Btn>
207
+ <Btn
208
+ on={() => editor.chain().focus().redo().run()}
209
+ title="Redo"
210
+ disabled={!editor.can().redo()}
211
+ >
212
+ <Redo2 className="size-3.5" />
213
+ </Btn>
214
+ <Divider />
215
+
216
+ <select
217
+ value={headingValue}
218
+ onChange={(e) => {
219
+ const v = e.target.value;
220
+ const chain = editor.chain().focus();
221
+ if (v === "p") chain.setParagraph().run();
222
+ else if (v === "code") chain.toggleCodeBlock().run();
223
+ else chain.setHeading({ level: Number(v.slice(1)) as 1 | 2 | 3 | 4 | 5 | 6 }).run();
224
+ }}
225
+ className="h-7 border border-ink/15 bg-white px-1 text-xs text-ink focus:border-sea focus:outline-none"
226
+ title="Text style"
227
+ >
228
+ <option value="p">Paragraph</option>
229
+ <option value="h1">Heading 1</option>
230
+ <option value="h2">Heading 2</option>
231
+ <option value="h3">Heading 3</option>
232
+ <option value="h4">Heading 4</option>
233
+ <option value="h5">Heading 5</option>
234
+ <option value="h6">Heading 6</option>
235
+ <option value="code">Code block</option>
236
+ </select>
237
+
238
+ <select
239
+ value={(editor.getAttributes("textStyle").fontFamily as string) ?? ""}
240
+ onChange={(e) => {
241
+ const v = e.target.value;
242
+ if (!v) editor.chain().focus().unsetFontFamily().run();
243
+ else editor.chain().focus().setFontFamily(v).run();
244
+ }}
245
+ className="h-7 border border-ink/15 bg-white px-1 text-xs text-ink focus:border-sea focus:outline-none"
246
+ title="Font"
247
+ >
248
+ {FONTS.map((f) => (
249
+ <option key={f.label} value={f.value}>
250
+ {f.label}
251
+ </option>
252
+ ))}
253
+ </select>
254
+
255
+ <select
256
+ value={(editor.getAttributes("textStyle").fontSize as string) ?? ""}
257
+ onChange={(e) => {
258
+ const v = e.target.value;
259
+ if (!v) editor.chain().focus().unsetFontSize().run();
260
+ else editor.chain().focus().setFontSize(v).run();
261
+ }}
262
+ className="h-7 border border-ink/15 bg-white px-1 text-xs text-ink focus:border-sea focus:outline-none"
263
+ title="Font size"
264
+ >
265
+ {SIZES.map((s) => (
266
+ <option key={s || "default"} value={s}>
267
+ {s || "Size"}
268
+ </option>
269
+ ))}
270
+ </select>
271
+ <Divider />
272
+
273
+ <Btn
274
+ on={() => editor.chain().focus().toggleBold().run()}
275
+ active={editor.isActive("bold")}
276
+ title="Bold"
277
+ >
278
+ <Bold className="size-3.5" />
279
+ </Btn>
280
+ <Btn
281
+ on={() => editor.chain().focus().toggleItalic().run()}
282
+ active={editor.isActive("italic")}
283
+ title="Italic"
284
+ >
285
+ <Italic className="size-3.5" />
286
+ </Btn>
287
+ <Btn
288
+ on={() => editor.chain().focus().toggleUnderline().run()}
289
+ active={editor.isActive("underline")}
290
+ title="Underline"
291
+ >
292
+ <UnderlineIcon className="size-3.5" />
293
+ </Btn>
294
+ <Btn
295
+ on={() => editor.chain().focus().toggleStrike().run()}
296
+ active={editor.isActive("strike")}
297
+ title="Strikethrough"
298
+ >
299
+ <Strikethrough className="size-3.5" />
300
+ </Btn>
301
+ <Btn
302
+ on={() => editor.chain().focus().toggleCode().run()}
303
+ active={editor.isActive("code")}
304
+ title="Inline code"
305
+ >
306
+ <Code className="size-3.5" />
307
+ </Btn>
308
+ <Btn
309
+ on={() => editor.chain().focus().toggleSubscript().run()}
310
+ active={editor.isActive("subscript")}
311
+ title="Subscript"
312
+ >
313
+ <SubIcon className="size-3.5" />
314
+ </Btn>
315
+ <Btn
316
+ on={() => editor.chain().focus().toggleSuperscript().run()}
317
+ active={editor.isActive("superscript")}
318
+ title="Superscript"
319
+ >
320
+ <SupIcon className="size-3.5" />
321
+ </Btn>
322
+ <Divider />
323
+
324
+ <ColorControl
325
+ label="A"
326
+ value={(editor.getAttributes("textStyle").color as string) ?? ""}
327
+ onPick={(c) => editor.chain().focus().setColor(c).run()}
328
+ onClear={() => editor.chain().focus().unsetColor().run()}
329
+ />
330
+ <ColorControl
331
+ label="BG"
332
+ value={(editor.getAttributes("highlight").color as string) ?? ""}
333
+ onPick={(c) => editor.chain().focus().setHighlight({ color: c }).run()}
334
+ onClear={() => editor.chain().focus().unsetHighlight().run()}
335
+ />
336
+ <Divider />
337
+
338
+ <Btn
339
+ on={() => editor.chain().focus().setTextAlign("left").run()}
340
+ active={editor.isActive({ textAlign: "left" })}
341
+ title="Align left"
342
+ >
343
+ <AlignLeft className="size-3.5" />
344
+ </Btn>
345
+ <Btn
346
+ on={() => editor.chain().focus().setTextAlign("center").run()}
347
+ active={editor.isActive({ textAlign: "center" })}
348
+ title="Align center"
349
+ >
350
+ <AlignCenter className="size-3.5" />
351
+ </Btn>
352
+ <Btn
353
+ on={() => editor.chain().focus().setTextAlign("right").run()}
354
+ active={editor.isActive({ textAlign: "right" })}
355
+ title="Align right"
356
+ >
357
+ <AlignRight className="size-3.5" />
358
+ </Btn>
359
+ <Btn
360
+ on={() => editor.chain().focus().setTextAlign("justify").run()}
361
+ active={editor.isActive({ textAlign: "justify" })}
362
+ title="Justify"
363
+ >
364
+ <AlignJustify className="size-3.5" />
365
+ </Btn>
366
+ <select
367
+ value={(editor.getAttributes("paragraph").lineHeight as string) ?? ""}
368
+ onChange={(e) => {
369
+ const v = e.target.value;
370
+ if (!v) editor.chain().focus().unsetLineHeight().run();
371
+ else editor.chain().focus().setLineHeight(v).run();
372
+ }}
373
+ className="h-7 border border-ink/15 bg-white px-1 text-xs text-ink focus:border-sea focus:outline-none"
374
+ title="Line height"
375
+ >
376
+ <option value="">Line height</option>
377
+ {["1", "1.25", "1.5", "1.75", "2"].map((v) => (
378
+ <option key={v} value={v}>
379
+ {v}
380
+ </option>
381
+ ))}
382
+ </select>
383
+ <Divider />
384
+
385
+ <Btn
386
+ on={() => editor.chain().focus().toggleBulletList().run()}
387
+ active={editor.isActive("bulletList")}
388
+ title="Bullet list"
389
+ >
390
+ <List className="size-3.5" />
391
+ </Btn>
392
+ <Btn
393
+ on={() => editor.chain().focus().toggleOrderedList().run()}
394
+ active={editor.isActive("orderedList")}
395
+ title="Numbered list"
396
+ >
397
+ <ListOrdered className="size-3.5" />
398
+ </Btn>
399
+ <Btn
400
+ on={() => editor.chain().focus().toggleBlockquote().run()}
401
+ active={editor.isActive("blockquote")}
402
+ title="Quote"
403
+ >
404
+ <Quote className="size-3.5" />
405
+ </Btn>
406
+ <Btn on={() => editor.chain().focus().setHorizontalRule().run()} title="Divider">
407
+ <Minus className="size-3.5" />
408
+ </Btn>
409
+ <Divider />
410
+
411
+ <Btn on={setLink} active={editor.isActive("link")} title="Add or edit link">
412
+ <LinkIcon className="size-3.5" />
413
+ </Btn>
414
+ <Btn
415
+ on={() => editor.chain().focus().extendMarkRange("link").unsetLink().run()}
416
+ title="Remove link"
417
+ disabled={!editor.isActive("link")}
418
+ >
419
+ <Unlink className="size-3.5" />
420
+ </Btn>
421
+ <Btn on={() => setPickImage(true)} title="Insert image from the media library">
422
+ <ImageIcon className="size-3.5" />
423
+ </Btn>
424
+ <Divider />
425
+
426
+ <Btn
427
+ on={() =>
428
+ editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
429
+ }
430
+ title="Insert table"
431
+ >
432
+ <TableIcon className="size-3.5" />
433
+ </Btn>
434
+ {editor.isActive("table") ? (
435
+ <div className="flex items-center gap-1 text-[0.65rem] uppercase tracking-widest text-ink/60">
436
+ <button
437
+ type="button"
438
+ onMouseDown={(e) => e.preventDefault()}
439
+ onClick={() => editor.chain().focus().addRowAfter().run()}
440
+ className="hover:text-ink"
441
+ >
442
+ +Row
443
+ </button>
444
+ <button
445
+ type="button"
446
+ onMouseDown={(e) => e.preventDefault()}
447
+ onClick={() => editor.chain().focus().addColumnAfter().run()}
448
+ className="hover:text-ink"
449
+ >
450
+ +Col
451
+ </button>
452
+ <button
453
+ type="button"
454
+ onMouseDown={(e) => e.preventDefault()}
455
+ onClick={() => editor.chain().focus().deleteRow().run()}
456
+ className="hover:text-ink"
457
+ >
458
+ -Row
459
+ </button>
460
+ <button
461
+ type="button"
462
+ onMouseDown={(e) => e.preventDefault()}
463
+ onClick={() => editor.chain().focus().deleteColumn().run()}
464
+ className="hover:text-ink"
465
+ >
466
+ -Col
467
+ </button>
468
+ <button
469
+ type="button"
470
+ onMouseDown={(e) => e.preventDefault()}
471
+ onClick={() => editor.chain().focus().deleteTable().run()}
472
+ className="text-red-500 hover:text-red-700"
473
+ >
474
+ Del table
475
+ </button>
476
+ </div>
477
+ ) : null}
478
+ <Divider />
479
+
480
+ <Btn
481
+ on={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
482
+ title="Clear formatting"
483
+ >
484
+ <Eraser className="size-3.5" />
485
+ </Btn>
486
+
487
+ {pickImage ? (
488
+ <MediaPickerDialog
489
+ accept="image"
490
+ onClose={() => setPickImage(false)}
491
+ onPick={(m) => {
492
+ editor.chain().focus().setImage({ src: m.url, alt: m.name }).run();
493
+ }}
494
+ />
495
+ ) : null}
496
+ </div>
497
+ );
498
+ }
499
+
500
+ /**
501
+ * Advanced WYSIWYG editor (TipTap) used by the rich-text block.
502
+ * Emits sanitized HTML; also offers a raw HTML source view for pasting
503
+ * markup from elsewhere.
504
+ */
505
+ export function RichTextEditor({
506
+ value,
507
+ onChange,
508
+ placeholder = "Write or paste your text…",
509
+ }: {
510
+ value: string;
511
+ onChange: (html: string) => void;
512
+ placeholder?: string;
513
+ }) {
514
+ const [source, setSource] = useState(false);
515
+ const [draft, setDraft] = useState(value);
516
+ const last = useRef(value);
517
+
518
+ const editor = useEditor({
519
+ immediatelyRender: false,
520
+ extensions: [
521
+ StarterKit.configure({
522
+ link: {
523
+ openOnClick: false,
524
+ autolink: true,
525
+ HTMLAttributes: { rel: "noopener noreferrer" },
526
+ },
527
+ heading: { levels: [1, 2, 3, 4, 5, 6] },
528
+ }),
529
+ TextStyle,
530
+ Color,
531
+ FontFamily,
532
+ FontSize,
533
+ LineHeight,
534
+ Highlight.configure({ multicolor: true }),
535
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
536
+ Subscript,
537
+ Superscript,
538
+ Image.configure({ inline: false, allowBase64: false }),
539
+ Table.configure({ resizable: true }),
540
+ TableRow,
541
+ TableHeader,
542
+ TableCell,
543
+ Placeholder.configure({ placeholder }),
544
+ ],
545
+ content: value || "",
546
+ editorProps: {
547
+ attributes: {
548
+ class: "rte-content focus:outline-none",
549
+ },
550
+ },
551
+ onUpdate: ({ editor: e }) => {
552
+ const html = sanitizeHtml(e.getHTML());
553
+ last.current = html;
554
+ onChange(html);
555
+ },
556
+ });
557
+
558
+ // Adopt external changes (locale switch, version rollback) without
559
+ // clobbering what the user is typing.
560
+ useEffect(() => {
561
+ if (!editor) return;
562
+ if (value === last.current) return;
563
+ last.current = value;
564
+ editor.commands.setContent(value || "", { emitUpdate: false });
565
+ setDraft(value);
566
+ }, [value, editor]);
567
+
568
+ const applySource = useCallback(() => {
569
+ const html = sanitizeHtml(draft);
570
+ last.current = html;
571
+ onChange(html);
572
+ editor?.commands.setContent(html || "", { emitUpdate: false });
573
+ setSource(false);
574
+ }, [draft, editor, onChange]);
575
+
576
+ return (
577
+ <div className="rte-editor border border-ink/15 bg-white">
578
+ {editor ? <Toolbar editor={editor} /> : null}
579
+ {source ? (
580
+ <div className="p-2">
581
+ <textarea
582
+ value={draft}
583
+ onChange={(e) => setDraft(e.target.value)}
584
+ spellCheck={false}
585
+ rows={16}
586
+ className="w-full border border-ink/15 p-2 font-mono text-xs text-ink focus:border-sea focus:outline-none"
587
+ />
588
+ <div className="mt-2 flex gap-2">
589
+ <button
590
+ type="button"
591
+ onClick={applySource}
592
+ className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-widest hover:border-sea"
593
+ >
594
+ Apply HTML
595
+ </button>
596
+ <button
597
+ type="button"
598
+ onClick={() => {
599
+ setDraft(value);
600
+ setSource(false);
601
+ }}
602
+ className="px-3 py-1.5 text-xs uppercase tracking-widest text-ink/60 hover:text-ink"
603
+ >
604
+ Cancel
605
+ </button>
606
+ </div>
607
+ </div>
608
+ ) : (
609
+ <div className="max-h-[32rem] overflow-auto p-4">
610
+ <EditorContent editor={editor} />
611
+ </div>
612
+ )}
613
+ <div className="flex items-center justify-between border-t border-ink/10 px-2 py-1">
614
+ <span className="text-[0.65rem] uppercase tracking-widest text-ink/40">
615
+ {editor
616
+ ? `${editor.storage.characterCount?.characters?.() ?? editor.getText().length} chars`
617
+ : ""}
618
+ </span>
619
+ <button
620
+ type="button"
621
+ onClick={() => {
622
+ setDraft(value);
623
+ setSource((s) => !s);
624
+ }}
625
+ className="flex items-center gap-1 text-[0.65rem] uppercase tracking-widest text-ink/60 hover:text-ink"
626
+ >
627
+ <Code2 className="size-3" />
628
+ {source ? "Visual editor" : "HTML source"}
629
+ </button>
630
+ </div>
631
+ </div>
632
+ );
633
+ }