@actuate-media/cms-admin 0.1.3 → 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.
Files changed (92) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +16 -10
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/actuate-admin.css +2 -0
  5. package/dist/lib/useApiData.d.ts +8 -1
  6. package/dist/lib/useApiData.d.ts.map +1 -1
  7. package/dist/lib/useApiData.js +39 -7
  8. package/dist/lib/useApiData.js.map +1 -1
  9. package/dist/views/Dashboard.d.ts.map +1 -1
  10. package/dist/views/Dashboard.js +8 -3
  11. package/dist/views/Dashboard.js.map +1 -1
  12. package/package.json +10 -5
  13. package/src/AdminRoot.tsx +312 -0
  14. package/src/__tests__/lib/search.test.ts +138 -0
  15. package/src/__tests__/lib/utils.test.ts +19 -0
  16. package/src/__tests__/router/match-route.test.ts +47 -0
  17. package/src/__tests__/router/strip-base.test.ts +30 -0
  18. package/src/components/Breadcrumbs.tsx +92 -0
  19. package/src/components/CommandPalette.tsx +384 -0
  20. package/src/components/ErrorBoundary.tsx +52 -0
  21. package/src/components/FocalPointPicker.tsx +54 -0
  22. package/src/components/FolderTree.tsx +427 -0
  23. package/src/components/LivePreview.tsx +136 -0
  24. package/src/components/LocaleProvider.tsx +51 -0
  25. package/src/components/LocaleSwitcher.tsx +51 -0
  26. package/src/components/MediaPickerModal.tsx +183 -0
  27. package/src/components/PresenceIndicator.tsx +71 -0
  28. package/src/components/SEOPanel.tsx +767 -0
  29. package/src/components/ThemeProvider.tsx +98 -0
  30. package/src/components/TipTapEditor.tsx +469 -0
  31. package/src/components/VersionHistory.tsx +167 -0
  32. package/src/components/ui/Avatar.tsx +42 -0
  33. package/src/components/ui/Badge.tsx +25 -0
  34. package/src/components/ui/Button.tsx +52 -0
  35. package/src/components/ui/CommandPalette.tsx +119 -0
  36. package/src/components/ui/ConfirmDialog.tsx +52 -0
  37. package/src/components/ui/DataTable.tsx +194 -0
  38. package/src/components/ui/EmptyState.tsx +29 -0
  39. package/src/components/ui/Modal.tsx +48 -0
  40. package/src/components/ui/Pagination.tsx +79 -0
  41. package/src/components/ui/SearchInput.tsx +44 -0
  42. package/src/components/ui/Skeleton.tsx +48 -0
  43. package/src/components/ui/Toast.tsx +66 -0
  44. package/src/components/ui/index.ts +24 -0
  45. package/src/fields/ArrayField.tsx +92 -0
  46. package/src/fields/BlockBuilderField.tsx +421 -0
  47. package/src/fields/DateField.tsx +41 -0
  48. package/src/fields/FieldRenderer.tsx +84 -0
  49. package/src/fields/GroupField.tsx +41 -0
  50. package/src/fields/MediaField.tsx +48 -0
  51. package/src/fields/NavBuilderField.tsx +78 -0
  52. package/src/fields/NumberField.tsx +45 -0
  53. package/src/fields/RelationshipField.tsx +245 -0
  54. package/src/fields/RichTextField.tsx +26 -0
  55. package/src/fields/SelectField.tsx +117 -0
  56. package/src/fields/SlugField.tsx +65 -0
  57. package/src/fields/TextField.tsx +48 -0
  58. package/src/fields/ToggleField.tsx +36 -0
  59. package/src/fields/block-types.ts +95 -0
  60. package/src/fields/index.ts +17 -0
  61. package/src/hooks/useContentLock.ts +52 -0
  62. package/src/hooks/useDebounce.ts +14 -0
  63. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  64. package/src/index.ts +55 -0
  65. package/src/layout/Header.tsx +135 -0
  66. package/src/layout/Layout.tsx +77 -0
  67. package/src/layout/Sidebar.tsx +216 -0
  68. package/src/lib/api.ts +67 -0
  69. package/src/lib/search.ts +59 -0
  70. package/src/lib/useApiData.ts +95 -0
  71. package/src/lib/utils.ts +6 -0
  72. package/src/router/index.ts +81 -0
  73. package/src/styles/build-input.css +11 -0
  74. package/src/styles/tailwind.css +7 -2
  75. package/src/styles/theme.css +2 -1
  76. package/src/views/CollectionList.tsx +270 -0
  77. package/src/views/Dashboard.tsx +207 -0
  78. package/src/views/DocumentEdit.tsx +377 -0
  79. package/src/views/FormEditor.tsx +533 -0
  80. package/src/views/FormSubmissions.tsx +316 -0
  81. package/src/views/Forms.tsx +106 -0
  82. package/src/views/Login.tsx +322 -0
  83. package/src/views/MediaBrowser.tsx +774 -0
  84. package/src/views/PageEditor.tsx +192 -0
  85. package/src/views/Pages.tsx +354 -0
  86. package/src/views/PostEditor.tsx +251 -0
  87. package/src/views/Posts.tsx +243 -0
  88. package/src/views/Redirects.tsx +293 -0
  89. package/src/views/SEO.tsx +458 -0
  90. package/src/views/Settings.tsx +811 -0
  91. package/src/views/SetupWizard.tsx +207 -0
  92. 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
+ }