@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.
|
|
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
|
+
}
|