@actuate-media/cms-admin 0.1.4 → 0.2.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/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +16 -10
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +2 -0
- package/dist/components/TipTapEditor.js +78 -78
- package/dist/lib/useApiData.d.ts +8 -1
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +39 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +8 -3
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +10 -5
- package/src/AdminRoot.tsx +312 -0
- package/src/__tests__/lib/search.test.ts +138 -0
- package/src/__tests__/lib/utils.test.ts +19 -0
- package/src/__tests__/router/match-route.test.ts +47 -0
- package/src/__tests__/router/strip-base.test.ts +30 -0
- package/src/components/Breadcrumbs.tsx +92 -0
- package/src/components/CommandPalette.tsx +384 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/FocalPointPicker.tsx +54 -0
- package/src/components/FolderTree.tsx +427 -0
- package/src/components/LivePreview.tsx +136 -0
- package/src/components/LocaleProvider.tsx +51 -0
- package/src/components/LocaleSwitcher.tsx +51 -0
- package/src/components/MediaPickerModal.tsx +183 -0
- package/src/components/PresenceIndicator.tsx +71 -0
- package/src/components/SEOPanel.tsx +767 -0
- package/src/components/ThemeProvider.tsx +98 -0
- package/src/components/TipTapEditor.tsx +469 -0
- package/src/components/VersionHistory.tsx +167 -0
- package/src/components/ui/Avatar.tsx +42 -0
- package/src/components/ui/Badge.tsx +25 -0
- package/src/components/ui/Button.tsx +52 -0
- package/src/components/ui/CommandPalette.tsx +119 -0
- package/src/components/ui/ConfirmDialog.tsx +52 -0
- package/src/components/ui/DataTable.tsx +194 -0
- package/src/components/ui/EmptyState.tsx +29 -0
- package/src/components/ui/Modal.tsx +48 -0
- package/src/components/ui/Pagination.tsx +79 -0
- package/src/components/ui/SearchInput.tsx +44 -0
- package/src/components/ui/Skeleton.tsx +48 -0
- package/src/components/ui/Toast.tsx +66 -0
- package/src/components/ui/index.ts +24 -0
- package/src/fields/ArrayField.tsx +92 -0
- package/src/fields/BlockBuilderField.tsx +421 -0
- package/src/fields/DateField.tsx +41 -0
- package/src/fields/FieldRenderer.tsx +84 -0
- package/src/fields/GroupField.tsx +41 -0
- package/src/fields/MediaField.tsx +48 -0
- package/src/fields/NavBuilderField.tsx +78 -0
- package/src/fields/NumberField.tsx +45 -0
- package/src/fields/RelationshipField.tsx +245 -0
- package/src/fields/RichTextField.tsx +26 -0
- package/src/fields/SelectField.tsx +117 -0
- package/src/fields/SlugField.tsx +65 -0
- package/src/fields/TextField.tsx +48 -0
- package/src/fields/ToggleField.tsx +36 -0
- package/src/fields/block-types.ts +95 -0
- package/src/fields/index.ts +17 -0
- package/src/hooks/useContentLock.ts +52 -0
- package/src/hooks/useDebounce.ts +14 -0
- package/src/hooks/useKeyboardShortcuts.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout/Header.tsx +135 -0
- package/src/layout/Layout.tsx +77 -0
- package/src/layout/Sidebar.tsx +216 -0
- package/src/lib/api.ts +67 -0
- package/src/lib/search.ts +59 -0
- package/src/lib/useApiData.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/router/index.ts +81 -0
- package/src/styles/build-input.css +11 -0
- package/src/styles/tailwind.css +11 -6
- package/src/styles/theme.css +182 -181
- package/src/views/CollectionList.tsx +270 -0
- package/src/views/Dashboard.tsx +207 -0
- package/src/views/DocumentEdit.tsx +377 -0
- package/src/views/FormEditor.tsx +533 -0
- package/src/views/FormSubmissions.tsx +316 -0
- package/src/views/Forms.tsx +106 -0
- package/src/views/Login.tsx +322 -0
- package/src/views/MediaBrowser.tsx +774 -0
- package/src/views/PageEditor.tsx +192 -0
- package/src/views/Pages.tsx +354 -0
- package/src/views/PostEditor.tsx +251 -0
- package/src/views/Posts.tsx +243 -0
- package/src/views/Redirects.tsx +293 -0
- package/src/views/SEO.tsx +458 -0
- package/src/views/Settings.tsx +811 -0
- package/src/views/SetupWizard.tsx +207 -0
- package/src/views/Users.tsx +282 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
type Theme = 'light' | 'dark' | 'system';
|
|
6
|
+
|
|
7
|
+
interface ThemeContextValue {
|
|
8
|
+
theme: Theme;
|
|
9
|
+
resolvedTheme: 'light' | 'dark';
|
|
10
|
+
setTheme: (theme: Theme) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ThemeContext = createContext<ThemeContextValue>({
|
|
14
|
+
theme: 'system',
|
|
15
|
+
resolvedTheme: 'light',
|
|
16
|
+
setTheme: () => {},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function useTheme() {
|
|
20
|
+
return useContext(ThemeContext);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getSystemTheme(): 'light' | 'dark' {
|
|
24
|
+
if (typeof window === 'undefined') return 'light';
|
|
25
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveTheme(theme: Theme): 'light' | 'dark' {
|
|
29
|
+
return theme === 'system' ? getSystemTheme() : theme;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const STORAGE_KEY = 'actuate-theme';
|
|
33
|
+
|
|
34
|
+
interface ThemeProviderProps {
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
/** When true, default to dark mode if the user hasn't explicitly chosen a theme. */
|
|
37
|
+
defaultDarkMode?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ThemeProvider({ children, defaultDarkMode }: ThemeProviderProps) {
|
|
41
|
+
const [theme, setThemeState] = useState<Theme>('system');
|
|
42
|
+
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
|
|
46
|
+
let initial: Theme;
|
|
47
|
+
if (stored && ['light', 'dark', 'system'].includes(stored)) {
|
|
48
|
+
initial = stored;
|
|
49
|
+
} else if (defaultDarkMode === true) {
|
|
50
|
+
initial = 'dark';
|
|
51
|
+
} else if (defaultDarkMode === false) {
|
|
52
|
+
initial = 'light';
|
|
53
|
+
} else {
|
|
54
|
+
initial = 'system';
|
|
55
|
+
}
|
|
56
|
+
setThemeState(initial);
|
|
57
|
+
const resolved = resolveTheme(initial);
|
|
58
|
+
setResolvedTheme(resolved);
|
|
59
|
+
applyTheme(resolved);
|
|
60
|
+
}, [defaultDarkMode]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
64
|
+
const handler = () => {
|
|
65
|
+
if (theme === 'system') {
|
|
66
|
+
const resolved = getSystemTheme();
|
|
67
|
+
setResolvedTheme(resolved);
|
|
68
|
+
applyTheme(resolved);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
mq.addEventListener('change', handler);
|
|
72
|
+
return () => mq.removeEventListener('change', handler);
|
|
73
|
+
}, [theme]);
|
|
74
|
+
|
|
75
|
+
const setTheme = useCallback((next: Theme) => {
|
|
76
|
+
setThemeState(next);
|
|
77
|
+
localStorage.setItem(STORAGE_KEY, next);
|
|
78
|
+
const resolved = resolveTheme(next);
|
|
79
|
+
setResolvedTheme(resolved);
|
|
80
|
+
applyTheme(resolved);
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
|
85
|
+
{children}
|
|
86
|
+
</ThemeContext.Provider>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function applyTheme(resolved: 'light' | 'dark') {
|
|
91
|
+
const adminRoot = document.querySelector('.actuate-admin');
|
|
92
|
+
if (!adminRoot) return;
|
|
93
|
+
if (resolved === 'dark') {
|
|
94
|
+
adminRoot.classList.add('dark');
|
|
95
|
+
} else {
|
|
96
|
+
adminRoot.classList.remove('dark');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
|
5
|
+
import { MediaPickerModal } from './MediaPickerModal.js';
|
|
6
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
7
|
+
import UnderlineExt from '@tiptap/extension-underline';
|
|
8
|
+
import Link from '@tiptap/extension-link';
|
|
9
|
+
import Image from '@tiptap/extension-image';
|
|
10
|
+
import Table from '@tiptap/extension-table';
|
|
11
|
+
import TableRow from '@tiptap/extension-table-row';
|
|
12
|
+
import TableHeader from '@tiptap/extension-table-header';
|
|
13
|
+
import TableCell from '@tiptap/extension-table-cell';
|
|
14
|
+
import TextAlign from '@tiptap/extension-text-align';
|
|
15
|
+
import Placeholder from '@tiptap/extension-placeholder';
|
|
16
|
+
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
|
17
|
+
import HorizontalRule from '@tiptap/extension-horizontal-rule';
|
|
18
|
+
import { common, createLowlight } from 'lowlight';
|
|
19
|
+
import {
|
|
20
|
+
Bold,
|
|
21
|
+
Italic,
|
|
22
|
+
Underline,
|
|
23
|
+
Strikethrough,
|
|
24
|
+
Code,
|
|
25
|
+
Heading1,
|
|
26
|
+
Heading2,
|
|
27
|
+
Heading3,
|
|
28
|
+
List,
|
|
29
|
+
ListOrdered,
|
|
30
|
+
Quote,
|
|
31
|
+
FileCode,
|
|
32
|
+
Minus,
|
|
33
|
+
AlignLeft,
|
|
34
|
+
AlignCenter,
|
|
35
|
+
AlignRight,
|
|
36
|
+
Link as LinkIcon,
|
|
37
|
+
Image as ImageIcon,
|
|
38
|
+
Table as TableIcon,
|
|
39
|
+
Columns,
|
|
40
|
+
Rows,
|
|
41
|
+
Trash2,
|
|
42
|
+
Undo,
|
|
43
|
+
Redo,
|
|
44
|
+
} from 'lucide-react';
|
|
45
|
+
|
|
46
|
+
const lowlight = createLowlight(common);
|
|
47
|
+
|
|
48
|
+
export interface TipTapEditorProps {
|
|
49
|
+
content: string;
|
|
50
|
+
onChange: (html: string) => void;
|
|
51
|
+
placeholder?: string;
|
|
52
|
+
editable?: boolean;
|
|
53
|
+
className?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const editorStyles = `
|
|
57
|
+
.ProseMirror {
|
|
58
|
+
min-height: 300px;
|
|
59
|
+
outline: none;
|
|
60
|
+
padding: 1rem;
|
|
61
|
+
}
|
|
62
|
+
.ProseMirror h1 { font-size: 2em; font-weight: 700; margin: 0.67em 0; }
|
|
63
|
+
.ProseMirror h2 { font-size: 1.5em; font-weight: 700; margin: 0.75em 0; }
|
|
64
|
+
.ProseMirror h3 { font-size: 1.17em; font-weight: 700; margin: 0.83em 0; }
|
|
65
|
+
.ProseMirror h4 { font-size: 1em; font-weight: 700; margin: 1em 0; }
|
|
66
|
+
.ProseMirror h5 { font-size: 0.83em; font-weight: 700; margin: 1.17em 0; }
|
|
67
|
+
.ProseMirror h6 { font-size: 0.67em; font-weight: 700; margin: 1.33em 0; }
|
|
68
|
+
.ProseMirror ul { list-style: disc; padding-left: 1.5rem; margin: 0.5em 0; }
|
|
69
|
+
.ProseMirror ol { list-style: decimal; padding-left: 1.5rem; margin: 0.5em 0; }
|
|
70
|
+
.ProseMirror li { margin: 0.25em 0; }
|
|
71
|
+
.ProseMirror p { margin: 0.5em 0; }
|
|
72
|
+
.ProseMirror table {
|
|
73
|
+
border-collapse: collapse;
|
|
74
|
+
width: 100%;
|
|
75
|
+
margin: 1rem 0;
|
|
76
|
+
}
|
|
77
|
+
.ProseMirror th,
|
|
78
|
+
.ProseMirror td {
|
|
79
|
+
border: 1px solid #d1d5db;
|
|
80
|
+
padding: 0.5rem;
|
|
81
|
+
text-align: left;
|
|
82
|
+
min-width: 80px;
|
|
83
|
+
}
|
|
84
|
+
.ProseMirror th {
|
|
85
|
+
background-color: #f3f4f6;
|
|
86
|
+
font-weight: 600;
|
|
87
|
+
}
|
|
88
|
+
.ProseMirror img {
|
|
89
|
+
max-width: 100%;
|
|
90
|
+
height: auto;
|
|
91
|
+
border-radius: 0.375rem;
|
|
92
|
+
}
|
|
93
|
+
.ProseMirror blockquote {
|
|
94
|
+
border-left: 3px solid #3b82f6;
|
|
95
|
+
padding-left: 1rem;
|
|
96
|
+
margin: 1rem 0;
|
|
97
|
+
color: #4b5563;
|
|
98
|
+
}
|
|
99
|
+
.ProseMirror pre {
|
|
100
|
+
background: #1e293b;
|
|
101
|
+
color: #e2e8f0;
|
|
102
|
+
padding: 1rem;
|
|
103
|
+
border-radius: 0.5rem;
|
|
104
|
+
overflow-x: auto;
|
|
105
|
+
margin: 0.75rem 0;
|
|
106
|
+
}
|
|
107
|
+
.ProseMirror code {
|
|
108
|
+
background: #f1f5f9;
|
|
109
|
+
padding: 0.125rem 0.375rem;
|
|
110
|
+
border-radius: 0.25rem;
|
|
111
|
+
font-size: 0.875em;
|
|
112
|
+
}
|
|
113
|
+
.ProseMirror pre code {
|
|
114
|
+
background: none;
|
|
115
|
+
padding: 0;
|
|
116
|
+
border-radius: 0;
|
|
117
|
+
font-size: inherit;
|
|
118
|
+
}
|
|
119
|
+
.ProseMirror hr {
|
|
120
|
+
border: none;
|
|
121
|
+
border-top: 2px solid #e5e7eb;
|
|
122
|
+
margin: 1.5rem 0;
|
|
123
|
+
}
|
|
124
|
+
.ProseMirror p.is-editor-empty:first-child::before {
|
|
125
|
+
content: attr(data-placeholder);
|
|
126
|
+
float: left;
|
|
127
|
+
color: #9ca3af;
|
|
128
|
+
pointer-events: none;
|
|
129
|
+
height: 0;
|
|
130
|
+
}
|
|
131
|
+
.ProseMirror .selectedCell {
|
|
132
|
+
background-color: #dbeafe;
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
function ToolbarButton({
|
|
137
|
+
onClick,
|
|
138
|
+
isActive = false,
|
|
139
|
+
disabled = false,
|
|
140
|
+
title,
|
|
141
|
+
children,
|
|
142
|
+
}: {
|
|
143
|
+
onClick: () => void;
|
|
144
|
+
isActive?: boolean;
|
|
145
|
+
disabled?: boolean;
|
|
146
|
+
title: string;
|
|
147
|
+
children: React.ReactNode;
|
|
148
|
+
}) {
|
|
149
|
+
return (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={onClick}
|
|
153
|
+
disabled={disabled}
|
|
154
|
+
title={title}
|
|
155
|
+
className={`p-1.5 rounded transition-colors ${
|
|
156
|
+
isActive
|
|
157
|
+
? 'bg-blue-100 text-blue-700'
|
|
158
|
+
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
|
159
|
+
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
|
160
|
+
>
|
|
161
|
+
{children}
|
|
162
|
+
</button>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function Divider() {
|
|
167
|
+
return <div className="w-px h-6 bg-gray-200 mx-1" />;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function Toolbar({ editor, onOpenMediaPicker }: { editor: Editor; onOpenMediaPicker: () => void }) {
|
|
171
|
+
const addLink = () => {
|
|
172
|
+
const previousUrl = editor.getAttributes('link').href ?? '';
|
|
173
|
+
const url = window.prompt('Enter URL:', previousUrl);
|
|
174
|
+
if (url === null) return;
|
|
175
|
+
if (url === '') {
|
|
176
|
+
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const insertTable = () => {
|
|
183
|
+
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const isInTable = editor.isActive('table');
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div className="border-b border-gray-200 bg-gray-50 p-2 flex items-center gap-0.5 flex-wrap sticky top-0 z-10">
|
|
190
|
+
{/* Group 1: Text formatting */}
|
|
191
|
+
<ToolbarButton
|
|
192
|
+
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
193
|
+
isActive={editor.isActive('bold')}
|
|
194
|
+
title="Bold (Ctrl+B)"
|
|
195
|
+
>
|
|
196
|
+
<Bold className="w-4 h-4" />
|
|
197
|
+
</ToolbarButton>
|
|
198
|
+
<ToolbarButton
|
|
199
|
+
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
200
|
+
isActive={editor.isActive('italic')}
|
|
201
|
+
title="Italic (Ctrl+I)"
|
|
202
|
+
>
|
|
203
|
+
<Italic className="w-4 h-4" />
|
|
204
|
+
</ToolbarButton>
|
|
205
|
+
<ToolbarButton
|
|
206
|
+
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
207
|
+
isActive={editor.isActive('underline')}
|
|
208
|
+
title="Underline (Ctrl+U)"
|
|
209
|
+
>
|
|
210
|
+
<Underline className="w-4 h-4" />
|
|
211
|
+
</ToolbarButton>
|
|
212
|
+
<ToolbarButton
|
|
213
|
+
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
214
|
+
isActive={editor.isActive('strike')}
|
|
215
|
+
title="Strikethrough"
|
|
216
|
+
>
|
|
217
|
+
<Strikethrough className="w-4 h-4" />
|
|
218
|
+
</ToolbarButton>
|
|
219
|
+
<ToolbarButton
|
|
220
|
+
onClick={() => editor.chain().focus().toggleCode().run()}
|
|
221
|
+
isActive={editor.isActive('code')}
|
|
222
|
+
title="Inline code"
|
|
223
|
+
>
|
|
224
|
+
<Code className="w-4 h-4" />
|
|
225
|
+
</ToolbarButton>
|
|
226
|
+
|
|
227
|
+
<Divider />
|
|
228
|
+
|
|
229
|
+
{/* Group 2: Headings */}
|
|
230
|
+
<ToolbarButton
|
|
231
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
232
|
+
isActive={editor.isActive('heading', { level: 1 })}
|
|
233
|
+
title="Heading 1"
|
|
234
|
+
>
|
|
235
|
+
<Heading1 className="w-4 h-4" />
|
|
236
|
+
</ToolbarButton>
|
|
237
|
+
<ToolbarButton
|
|
238
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
239
|
+
isActive={editor.isActive('heading', { level: 2 })}
|
|
240
|
+
title="Heading 2"
|
|
241
|
+
>
|
|
242
|
+
<Heading2 className="w-4 h-4" />
|
|
243
|
+
</ToolbarButton>
|
|
244
|
+
<ToolbarButton
|
|
245
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
246
|
+
isActive={editor.isActive('heading', { level: 3 })}
|
|
247
|
+
title="Heading 3"
|
|
248
|
+
>
|
|
249
|
+
<Heading3 className="w-4 h-4" />
|
|
250
|
+
</ToolbarButton>
|
|
251
|
+
|
|
252
|
+
<Divider />
|
|
253
|
+
|
|
254
|
+
{/* Group 3: Lists & blocks */}
|
|
255
|
+
<ToolbarButton
|
|
256
|
+
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
257
|
+
isActive={editor.isActive('bulletList')}
|
|
258
|
+
title="Bullet list"
|
|
259
|
+
>
|
|
260
|
+
<List className="w-4 h-4" />
|
|
261
|
+
</ToolbarButton>
|
|
262
|
+
<ToolbarButton
|
|
263
|
+
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
264
|
+
isActive={editor.isActive('orderedList')}
|
|
265
|
+
title="Ordered list"
|
|
266
|
+
>
|
|
267
|
+
<ListOrdered className="w-4 h-4" />
|
|
268
|
+
</ToolbarButton>
|
|
269
|
+
<ToolbarButton
|
|
270
|
+
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
271
|
+
isActive={editor.isActive('blockquote')}
|
|
272
|
+
title="Blockquote"
|
|
273
|
+
>
|
|
274
|
+
<Quote className="w-4 h-4" />
|
|
275
|
+
</ToolbarButton>
|
|
276
|
+
<ToolbarButton
|
|
277
|
+
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
278
|
+
isActive={editor.isActive('codeBlock')}
|
|
279
|
+
title="Code block"
|
|
280
|
+
>
|
|
281
|
+
<FileCode className="w-4 h-4" />
|
|
282
|
+
</ToolbarButton>
|
|
283
|
+
<ToolbarButton
|
|
284
|
+
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
|
285
|
+
title="Horizontal rule"
|
|
286
|
+
>
|
|
287
|
+
<Minus className="w-4 h-4" />
|
|
288
|
+
</ToolbarButton>
|
|
289
|
+
|
|
290
|
+
<Divider />
|
|
291
|
+
|
|
292
|
+
{/* Group 4: Alignment */}
|
|
293
|
+
<ToolbarButton
|
|
294
|
+
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
|
295
|
+
isActive={editor.isActive({ textAlign: 'left' })}
|
|
296
|
+
title="Align left"
|
|
297
|
+
>
|
|
298
|
+
<AlignLeft className="w-4 h-4" />
|
|
299
|
+
</ToolbarButton>
|
|
300
|
+
<ToolbarButton
|
|
301
|
+
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
|
302
|
+
isActive={editor.isActive({ textAlign: 'center' })}
|
|
303
|
+
title="Align center"
|
|
304
|
+
>
|
|
305
|
+
<AlignCenter className="w-4 h-4" />
|
|
306
|
+
</ToolbarButton>
|
|
307
|
+
<ToolbarButton
|
|
308
|
+
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
|
309
|
+
isActive={editor.isActive({ textAlign: 'right' })}
|
|
310
|
+
title="Align right"
|
|
311
|
+
>
|
|
312
|
+
<AlignRight className="w-4 h-4" />
|
|
313
|
+
</ToolbarButton>
|
|
314
|
+
|
|
315
|
+
<Divider />
|
|
316
|
+
|
|
317
|
+
{/* Group 5: Insert */}
|
|
318
|
+
<ToolbarButton
|
|
319
|
+
onClick={addLink}
|
|
320
|
+
isActive={editor.isActive('link')}
|
|
321
|
+
title="Insert link"
|
|
322
|
+
>
|
|
323
|
+
<LinkIcon className="w-4 h-4" />
|
|
324
|
+
</ToolbarButton>
|
|
325
|
+
<ToolbarButton
|
|
326
|
+
onClick={onOpenMediaPicker}
|
|
327
|
+
title="Insert image"
|
|
328
|
+
>
|
|
329
|
+
<ImageIcon className="w-4 h-4" />
|
|
330
|
+
</ToolbarButton>
|
|
331
|
+
<ToolbarButton
|
|
332
|
+
onClick={insertTable}
|
|
333
|
+
title="Insert table (3×3)"
|
|
334
|
+
>
|
|
335
|
+
<TableIcon className="w-4 h-4" />
|
|
336
|
+
</ToolbarButton>
|
|
337
|
+
|
|
338
|
+
{/* Group 6: Table controls (visible only when cursor is inside a table) */}
|
|
339
|
+
{isInTable && (
|
|
340
|
+
<>
|
|
341
|
+
<Divider />
|
|
342
|
+
<ToolbarButton
|
|
343
|
+
onClick={() => editor.chain().focus().addColumnBefore().run()}
|
|
344
|
+
title="Add column before"
|
|
345
|
+
>
|
|
346
|
+
<Columns className="w-4 h-4" />
|
|
347
|
+
</ToolbarButton>
|
|
348
|
+
<ToolbarButton
|
|
349
|
+
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
|
350
|
+
title="Add column after"
|
|
351
|
+
>
|
|
352
|
+
<Columns className="w-4 h-4" />
|
|
353
|
+
</ToolbarButton>
|
|
354
|
+
<ToolbarButton
|
|
355
|
+
onClick={() => editor.chain().focus().deleteColumn().run()}
|
|
356
|
+
title="Delete column"
|
|
357
|
+
>
|
|
358
|
+
<Columns className="w-4 h-4" />
|
|
359
|
+
</ToolbarButton>
|
|
360
|
+
<ToolbarButton
|
|
361
|
+
onClick={() => editor.chain().focus().addRowBefore().run()}
|
|
362
|
+
title="Add row before"
|
|
363
|
+
>
|
|
364
|
+
<Rows className="w-4 h-4" />
|
|
365
|
+
</ToolbarButton>
|
|
366
|
+
<ToolbarButton
|
|
367
|
+
onClick={() => editor.chain().focus().addRowAfter().run()}
|
|
368
|
+
title="Add row after"
|
|
369
|
+
>
|
|
370
|
+
<Rows className="w-4 h-4" />
|
|
371
|
+
</ToolbarButton>
|
|
372
|
+
<ToolbarButton
|
|
373
|
+
onClick={() => editor.chain().focus().deleteRow().run()}
|
|
374
|
+
title="Delete row"
|
|
375
|
+
>
|
|
376
|
+
<Rows className="w-4 h-4" />
|
|
377
|
+
</ToolbarButton>
|
|
378
|
+
<ToolbarButton
|
|
379
|
+
onClick={() => editor.chain().focus().deleteTable().run()}
|
|
380
|
+
title="Delete table"
|
|
381
|
+
>
|
|
382
|
+
<Trash2 className="w-4 h-4" />
|
|
383
|
+
</ToolbarButton>
|
|
384
|
+
</>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
<Divider />
|
|
388
|
+
|
|
389
|
+
{/* Group 7: History */}
|
|
390
|
+
<ToolbarButton
|
|
391
|
+
onClick={() => editor.chain().focus().undo().run()}
|
|
392
|
+
disabled={!editor.can().undo()}
|
|
393
|
+
title="Undo"
|
|
394
|
+
>
|
|
395
|
+
<Undo className="w-4 h-4" />
|
|
396
|
+
</ToolbarButton>
|
|
397
|
+
<ToolbarButton
|
|
398
|
+
onClick={() => editor.chain().focus().redo().run()}
|
|
399
|
+
disabled={!editor.can().redo()}
|
|
400
|
+
title="Redo"
|
|
401
|
+
>
|
|
402
|
+
<Redo className="w-4 h-4" />
|
|
403
|
+
</ToolbarButton>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function TipTapEditor({
|
|
409
|
+
content,
|
|
410
|
+
onChange,
|
|
411
|
+
placeholder,
|
|
412
|
+
editable = true,
|
|
413
|
+
className,
|
|
414
|
+
}: TipTapEditorProps) {
|
|
415
|
+
const [mediaPickerOpen, setMediaPickerOpen] = useState(false);
|
|
416
|
+
|
|
417
|
+
const editor = useEditor({
|
|
418
|
+
extensions: [
|
|
419
|
+
StarterKit.configure({
|
|
420
|
+
codeBlock: false,
|
|
421
|
+
horizontalRule: false,
|
|
422
|
+
}),
|
|
423
|
+
UnderlineExt,
|
|
424
|
+
Link.configure({
|
|
425
|
+
openOnClick: false,
|
|
426
|
+
HTMLAttributes: { class: 'text-blue-600 underline' },
|
|
427
|
+
}),
|
|
428
|
+
Image.configure({
|
|
429
|
+
inline: true,
|
|
430
|
+
allowBase64: true,
|
|
431
|
+
}),
|
|
432
|
+
Table.configure({ resizable: true }),
|
|
433
|
+
TableRow,
|
|
434
|
+
TableHeader,
|
|
435
|
+
TableCell,
|
|
436
|
+
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
437
|
+
Placeholder.configure({
|
|
438
|
+
placeholder: placeholder || 'Start writing...',
|
|
439
|
+
}),
|
|
440
|
+
CodeBlockLowlight.configure({ lowlight }),
|
|
441
|
+
HorizontalRule,
|
|
442
|
+
],
|
|
443
|
+
content,
|
|
444
|
+
editable: editable !== false,
|
|
445
|
+
onUpdate: ({ editor: ed }) => {
|
|
446
|
+
onChange(ed.getHTML());
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
if (!editor) return null;
|
|
451
|
+
|
|
452
|
+
const handleImageSelected = (url: string, alt?: string) => {
|
|
453
|
+
editor.chain().focus().setImage({ src: url, alt: alt ?? '' }).run();
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
return (
|
|
457
|
+
<div className={`border border-gray-200 rounded-lg overflow-hidden bg-white ${className ?? ''}`}>
|
|
458
|
+
{editable && <Toolbar editor={editor} onOpenMediaPicker={() => setMediaPickerOpen(true)} />}
|
|
459
|
+
<EditorContent editor={editor} />
|
|
460
|
+
<style dangerouslySetInnerHTML={{ __html: editorStyles }} />
|
|
461
|
+
<MediaPickerModal
|
|
462
|
+
open={mediaPickerOpen}
|
|
463
|
+
onClose={() => setMediaPickerOpen(false)}
|
|
464
|
+
onSelect={handleImageSelected}
|
|
465
|
+
accept="image/*"
|
|
466
|
+
/>
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|