@bygghammar/cms-platform 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -72,7 +72,11 @@
72
72
  "./backoffice/forms/ConditionBuilder": "./src/backoffice/forms/ConditionBuilder.tsx",
73
73
  "./backoffice/forms/RuleSimulator": "./src/backoffice/forms/RuleSimulator.tsx",
74
74
  "./backoffice/forms/SmartTags": "./src/backoffice/forms/SmartTags.tsx",
75
- "./backoffice/forms/ActivityLog": "./src/backoffice/forms/ActivityLog.tsx"
75
+ "./backoffice/forms/ActivityLog": "./src/backoffice/forms/ActivityLog.tsx",
76
+ "./backoffice/ProgressBar": "./src/backoffice/ProgressBar.tsx",
77
+ "./backoffice/PreviewLightbox": "./src/backoffice/PreviewLightbox.tsx",
78
+ "./backoffice/RichTextEditor": "./src/backoffice/RichTextEditor.tsx",
79
+ "./backoffice/media/ConfirmDialog": "./src/backoffice/media/ConfirmDialog.tsx"
76
80
  },
77
81
  "dependencies": {
78
82
  "fflate": "^0.8.3"
@@ -85,7 +89,18 @@
85
89
  "sonner": ">=2",
86
90
  "@supabase/supabase-js": ">=2",
87
91
  "@tanstack/react-start": ">=1",
88
- "@tanstack/react-router": ">=1"
92
+ "@tanstack/react-router": ">=1",
93
+ "@tiptap/react": ">=3",
94
+ "@tiptap/starter-kit": ">=3",
95
+ "@tiptap/pm": ">=3",
96
+ "@tiptap/extensions": ">=3",
97
+ "@tiptap/extension-image": ">=3",
98
+ "@tiptap/extension-text-align": ">=3",
99
+ "@tiptap/extension-highlight": ">=3",
100
+ "@tiptap/extension-subscript": ">=3",
101
+ "@tiptap/extension-superscript": ">=3",
102
+ "@tiptap/extension-text-style": ">=3",
103
+ "@tiptap/extension-table": ">=3"
89
104
  },
90
105
  "peerDependenciesMeta": {
91
106
  "@supabase/supabase-js": {
@@ -96,6 +111,39 @@
96
111
  },
97
112
  "@tanstack/react-router": {
98
113
  "optional": true
114
+ },
115
+ "@tiptap/react": {
116
+ "optional": true
117
+ },
118
+ "@tiptap/starter-kit": {
119
+ "optional": true
120
+ },
121
+ "@tiptap/pm": {
122
+ "optional": true
123
+ },
124
+ "@tiptap/extensions": {
125
+ "optional": true
126
+ },
127
+ "@tiptap/extension-image": {
128
+ "optional": true
129
+ },
130
+ "@tiptap/extension-text-align": {
131
+ "optional": true
132
+ },
133
+ "@tiptap/extension-highlight": {
134
+ "optional": true
135
+ },
136
+ "@tiptap/extension-subscript": {
137
+ "optional": true
138
+ },
139
+ "@tiptap/extension-superscript": {
140
+ "optional": true
141
+ },
142
+ "@tiptap/extension-text-style": {
143
+ "optional": true
144
+ },
145
+ "@tiptap/extension-table": {
146
+ "optional": true
99
147
  }
100
148
  }
101
149
  }
@@ -0,0 +1,66 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Lightbox } from "../ui/Lightbox";
3
+
4
+ const DESIGN_WIDTH = 1280;
5
+
6
+ /**
7
+ * Backoffice preview lightbox: renders a route inside a scaled iframe.
8
+ * Closing follows the shared site behaviour — Escape, backdrop click, or a
9
+ * click on the preview itself (the frame is look-only, covered by a
10
+ * transparent click catcher because iframe clicks never bubble out).
11
+ */
12
+ export function PreviewLightbox({
13
+ open,
14
+ onClose,
15
+ src,
16
+ title,
17
+ }: {
18
+ open: boolean;
19
+ onClose: () => void;
20
+ src: string;
21
+ title: string;
22
+ }) {
23
+ const [scale, setScale] = useState(1);
24
+ const [frameHeight, setFrameHeight] = useState(800);
25
+
26
+ useEffect(() => {
27
+ if (!open) return;
28
+ const measure = () => {
29
+ const availW = Math.max(320, window.innerWidth - 96);
30
+ const availH = Math.max(320, window.innerHeight - 120);
31
+ setScale(Math.min(1, availW / DESIGN_WIDTH));
32
+ setFrameHeight(Math.round(availH / Math.min(1, availW / DESIGN_WIDTH)));
33
+ };
34
+ measure();
35
+ window.addEventListener("resize", measure);
36
+ return () => window.removeEventListener("resize", measure);
37
+ }, [open]);
38
+
39
+ if (!open) return null;
40
+
41
+ return (
42
+ <Lightbox open={open} onClose={onClose} label={title} closeOnContentClick>
43
+ <div
44
+ className="relative bg-white shadow-2xl overflow-hidden"
45
+ style={{
46
+ width: DESIGN_WIDTH * scale,
47
+ height: frameHeight * scale,
48
+ }}
49
+ >
50
+ <iframe
51
+ src={src}
52
+ title={title}
53
+ className="absolute left-0 top-0 border-0"
54
+ style={{
55
+ width: DESIGN_WIDTH,
56
+ height: frameHeight,
57
+ transform: `scale(${scale})`,
58
+ transformOrigin: "top left",
59
+ }}
60
+ />
61
+ {/* Click catcher: iframe clicks can't bubble to the overlay. */}
62
+ <div className="absolute inset-0 cursor-pointer" onClick={onClose} />
63
+ </div>
64
+ </Lightbox>
65
+ );
66
+ }
@@ -0,0 +1,36 @@
1
+ type Props = {
2
+ /** 0–100, or null for an indeterminate bar */
3
+ value: number | null;
4
+ label?: string;
5
+ detail?: string;
6
+ };
7
+
8
+ /** Slim determinate/indeterminate progress bar used by the backoffice long-running tools. */
9
+ export function ProgressBar({ value, label, detail }: Props) {
10
+ const pct = value === null ? null : Math.max(0, Math.min(100, value));
11
+ return (
12
+ <div className="w-full">
13
+ {label || detail ? (
14
+ <div className="mb-2 flex items-baseline justify-between gap-4">
15
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
16
+ <span className="text-[0.65rem] tabular-nums text-ink/45">
17
+ {detail}
18
+ {pct !== null ? `${detail ? " · " : ""}${Math.round(pct)}%` : ""}
19
+ </span>
20
+ </div>
21
+ ) : null}
22
+ <div className="h-1.5 w-full overflow-hidden bg-ink/10">
23
+ {pct === null ? (
24
+ <div className="h-full w-1/3 animate-[progress-slide_1.2s_ease-in-out_infinite] bg-sea" />
25
+ ) : (
26
+ <div
27
+ className="h-full bg-sea transition-[width] duration-300 ease-out"
28
+ style={{ width: `${pct}%` }}
29
+ />
30
+ )}
31
+ </div>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ export default ProgressBar;
@@ -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
+ }
@@ -0,0 +1,77 @@
1
+ import { Loader2 } from "lucide-react";
2
+
3
+ /**
4
+ * Small backoffice confirmation modal (English UI) used before destructive or
5
+ * bulk actions in the Media Library.
6
+ */
7
+ export function ConfirmDialog({
8
+ title,
9
+ description,
10
+ details,
11
+ confirmLabel = "Confirm",
12
+ cancelLabel = "Cancel",
13
+ danger = false,
14
+ busy = false,
15
+ onConfirm,
16
+ onCancel,
17
+ }: {
18
+ title: string;
19
+ description?: string;
20
+ details?: string[];
21
+ confirmLabel?: string;
22
+ cancelLabel?: string;
23
+ danger?: boolean;
24
+ busy?: boolean;
25
+ onConfirm: () => void;
26
+ onCancel: () => void;
27
+ }) {
28
+ return (
29
+ <div
30
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
31
+ onClick={() => !busy && onCancel()}
32
+ >
33
+ <div
34
+ className="w-full max-w-md border border-ink/15 bg-white"
35
+ onClick={(e) => e.stopPropagation()}
36
+ >
37
+ <div className="border-b border-ink/10 px-4 py-3">
38
+ <h2 className="text-sm font-medium text-ink">{title}</h2>
39
+ </div>
40
+ <div className="space-y-3 px-4 py-4">
41
+ {description ? <p className="text-sm text-ink/70">{description}</p> : null}
42
+ {details?.length ? (
43
+ <ul className="max-h-40 space-y-1 overflow-auto border border-ink/10 bg-ice/30 p-2 text-[0.72rem] text-ink/70">
44
+ {details.map((d) => (
45
+ <li key={d} className="truncate" title={d}>
46
+ {d}
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ ) : null}
51
+ </div>
52
+ <div className="flex justify-end gap-2 border-t border-ink/10 px-4 py-3">
53
+ <button
54
+ type="button"
55
+ disabled={busy}
56
+ onClick={onCancel}
57
+ className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-ink disabled:opacity-50"
58
+ >
59
+ {cancelLabel}
60
+ </button>
61
+ <button
62
+ type="button"
63
+ disabled={busy}
64
+ onClick={onConfirm}
65
+ className={[
66
+ "inline-flex items-center gap-1.5 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-white disabled:opacity-50",
67
+ danger ? "bg-red-600 hover:bg-red-700" : "bg-ink hover:bg-sea",
68
+ ].join(" ")}
69
+ >
70
+ {busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
71
+ {confirmLabel}
72
+ </button>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ );
77
+ }