@jhits/plugin-blog 0.0.1
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/README.md +216 -0
- package/package.json +57 -0
- package/src/api/README.md +224 -0
- package/src/api/categories.ts +43 -0
- package/src/api/check-title.ts +60 -0
- package/src/api/handler.ts +419 -0
- package/src/api/index.ts +33 -0
- package/src/api/route.ts +116 -0
- package/src/api/router.ts +114 -0
- package/src/api-server.ts +11 -0
- package/src/config.ts +161 -0
- package/src/hooks/README.md +91 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/useBlog.ts +85 -0
- package/src/hooks/useBlogs.ts +123 -0
- package/src/index.server.ts +12 -0
- package/src/index.tsx +354 -0
- package/src/init.tsx +72 -0
- package/src/lib/blocks/BlockRenderer.tsx +141 -0
- package/src/lib/blocks/index.ts +6 -0
- package/src/lib/index.ts +9 -0
- package/src/lib/layouts/blocks/ColumnsBlock.tsx +134 -0
- package/src/lib/layouts/blocks/SectionBlock.tsx +104 -0
- package/src/lib/layouts/blocks/index.ts +8 -0
- package/src/lib/layouts/index.ts +52 -0
- package/src/lib/layouts/registerLayoutBlocks.ts +59 -0
- package/src/lib/mappers/apiMapper.ts +223 -0
- package/src/lib/migration/index.ts +6 -0
- package/src/lib/migration/mapper.ts +140 -0
- package/src/lib/rich-text/RichTextEditor.tsx +826 -0
- package/src/lib/rich-text/RichTextPreview.tsx +210 -0
- package/src/lib/rich-text/index.ts +10 -0
- package/src/lib/utils/blockHelpers.ts +72 -0
- package/src/lib/utils/configValidation.ts +137 -0
- package/src/lib/utils/index.ts +8 -0
- package/src/lib/utils/slugify.ts +79 -0
- package/src/registry/BlockRegistry.ts +142 -0
- package/src/registry/index.ts +11 -0
- package/src/state/EditorContext.tsx +277 -0
- package/src/state/index.ts +8 -0
- package/src/state/reducer.ts +694 -0
- package/src/state/types.ts +160 -0
- package/src/types/block.ts +269 -0
- package/src/types/index.ts +15 -0
- package/src/types/post.ts +165 -0
- package/src/utils/README.md +75 -0
- package/src/utils/client.ts +122 -0
- package/src/utils/index.ts +9 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +459 -0
- package/src/views/CanvasEditor/CanvasEditorView.tsx +917 -0
- package/src/views/CanvasEditor/EditorBody.tsx +475 -0
- package/src/views/CanvasEditor/EditorHeader.tsx +179 -0
- package/src/views/CanvasEditor/LayoutContainer.tsx +494 -0
- package/src/views/CanvasEditor/SaveConfirmationModal.tsx +233 -0
- package/src/views/CanvasEditor/components/CustomBlockItem.tsx +92 -0
- package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +130 -0
- package/src/views/CanvasEditor/components/LibraryItem.tsx +80 -0
- package/src/views/CanvasEditor/components/PrivacySettingsSection.tsx +212 -0
- package/src/views/CanvasEditor/components/index.ts +17 -0
- package/src/views/CanvasEditor/index.ts +16 -0
- package/src/views/PostManager/EmptyState.tsx +42 -0
- package/src/views/PostManager/PostActionsMenu.tsx +112 -0
- package/src/views/PostManager/PostCards.tsx +192 -0
- package/src/views/PostManager/PostFilters.tsx +80 -0
- package/src/views/PostManager/PostManagerView.tsx +280 -0
- package/src/views/PostManager/PostStats.tsx +81 -0
- package/src/views/PostManager/PostTable.tsx +225 -0
- package/src/views/PostManager/index.ts +15 -0
- package/src/views/Preview/PreviewBridgeView.tsx +64 -0
- package/src/views/Preview/index.ts +7 -0
- package/src/views/README.md +82 -0
- package/src/views/Settings/SettingsView.tsx +298 -0
- package/src/views/Settings/index.ts +7 -0
- package/src/views/SlugSEO/SlugSEOManagerView.tsx +94 -0
- package/src/views/SlugSEO/index.ts +7 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Utilities
|
|
3
|
+
* Helper functions for fetching blog data in client applications
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { apiToBlogPost, type APIBlogDocument } from '../lib/mappers/apiMapper';
|
|
7
|
+
import { PostListItem, BlogPost } from '../types/post';
|
|
8
|
+
|
|
9
|
+
export interface FetchBlogsOptions {
|
|
10
|
+
/** Maximum number of posts to fetch (default: 10) */
|
|
11
|
+
limit?: number;
|
|
12
|
+
/** Number of posts to skip (default: 0) */
|
|
13
|
+
skip?: number;
|
|
14
|
+
/** Filter by status (published, draft, concept) */
|
|
15
|
+
status?: string;
|
|
16
|
+
/** Whether to fetch all posts for admin (includes drafts) */
|
|
17
|
+
admin?: boolean;
|
|
18
|
+
/** API base URL (default: '/api/blogs') */
|
|
19
|
+
apiBaseUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FetchBlogsResult {
|
|
23
|
+
/** Array of blog posts */
|
|
24
|
+
blogs: PostListItem[];
|
|
25
|
+
/** Total number of posts available */
|
|
26
|
+
total: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fetch blog posts from the API
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const { blogs, total } = await fetchBlogs({ limit: 5 });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchBlogs(options: FetchBlogsOptions = {}): Promise<FetchBlogsResult> {
|
|
38
|
+
const {
|
|
39
|
+
limit = 10,
|
|
40
|
+
skip = 0,
|
|
41
|
+
status,
|
|
42
|
+
admin = false,
|
|
43
|
+
apiBaseUrl = '/api/plugin-blog',
|
|
44
|
+
} = options;
|
|
45
|
+
|
|
46
|
+
const params = new URLSearchParams();
|
|
47
|
+
if (limit) params.set('limit', limit.toString());
|
|
48
|
+
if (skip) params.set('skip', skip.toString());
|
|
49
|
+
if (status) params.set('status', status);
|
|
50
|
+
if (admin) params.set('admin', 'true');
|
|
51
|
+
|
|
52
|
+
const url = `${apiBaseUrl}?${params.toString()}`;
|
|
53
|
+
const response = await fetch(url);
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new Error(`Failed to fetch blogs: ${response.status}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const data = await response.json();
|
|
60
|
+
|
|
61
|
+
// Handle error response
|
|
62
|
+
if (data.error) {
|
|
63
|
+
throw new Error(data.error || 'Failed to fetch blogs');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Convert API format to PostListItem format
|
|
67
|
+
const blogsArray = Array.isArray(data.blogs) ? data.blogs : [];
|
|
68
|
+
const convertedBlogs: PostListItem[] = blogsArray.map((apiDoc: APIBlogDocument) => {
|
|
69
|
+
const blogPost = apiToBlogPost(apiDoc);
|
|
70
|
+
return {
|
|
71
|
+
id: blogPost.id,
|
|
72
|
+
title: blogPost.title,
|
|
73
|
+
slug: blogPost.slug,
|
|
74
|
+
excerpt: blogPost.metadata.excerpt || '',
|
|
75
|
+
status: blogPost.publication.status,
|
|
76
|
+
authorId: blogPost.publication.authorId || '',
|
|
77
|
+
updatedAt: blogPost.updatedAt,
|
|
78
|
+
featuredImage: blogPost.metadata.featuredImage,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
blogs: convertedBlogs,
|
|
84
|
+
total: data.total || convertedBlogs.length,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FetchBlogOptions {
|
|
89
|
+
/** Blog post slug */
|
|
90
|
+
slug: string;
|
|
91
|
+
/** API base URL (default: '/api/blogs') */
|
|
92
|
+
apiBaseUrl?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fetch a single blog post by slug
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```ts
|
|
100
|
+
* const blog = await fetchBlog({ slug: 'my-blog-post' });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export async function fetchBlog(options: FetchBlogOptions): Promise<BlogPost> {
|
|
104
|
+
const { slug, apiBaseUrl = '/api/plugin-blog' } = options;
|
|
105
|
+
|
|
106
|
+
if (!slug) {
|
|
107
|
+
throw new Error('Slug is required');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const response = await fetch(`${apiBaseUrl}/${slug}`);
|
|
111
|
+
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
if (response.status === 404) {
|
|
114
|
+
throw new Error('Blog post not found');
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`Failed to fetch blog: ${response.status}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const apiDoc: APIBlogDocument = await response.json();
|
|
120
|
+
return apiToBlogPost(apiDoc);
|
|
121
|
+
}
|
|
122
|
+
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block Wrapper Component
|
|
3
|
+
* Provides hover controls (Delete, Move, Settings) for each block
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
9
|
+
import { Plus, Trash2, ChevronUp, ChevronDown, Settings2, GripVertical, Copy } from 'lucide-react';
|
|
10
|
+
import { Block } from '../../types/block';
|
|
11
|
+
import { blockRegistry } from '../../registry/BlockRegistry';
|
|
12
|
+
import { getChildBlocks, isContainerBlock } from '../../lib/utils/blockHelpers';
|
|
13
|
+
import { useEditor } from '../../state/EditorContext';
|
|
14
|
+
|
|
15
|
+
export interface BlockWrapperProps {
|
|
16
|
+
block: Block;
|
|
17
|
+
onUpdate: (data: Partial<Block['data']>) => void;
|
|
18
|
+
onDelete: () => void;
|
|
19
|
+
onMoveUp?: () => void;
|
|
20
|
+
onMoveDown?: () => void;
|
|
21
|
+
/** All blocks in the editor (for resolving child block IDs) */
|
|
22
|
+
allBlocks?: Block[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function BlockWrapper({
|
|
26
|
+
block,
|
|
27
|
+
onUpdate,
|
|
28
|
+
onDelete,
|
|
29
|
+
onMoveUp,
|
|
30
|
+
onMoveDown,
|
|
31
|
+
allBlocks = [],
|
|
32
|
+
}: BlockWrapperProps) {
|
|
33
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
34
|
+
const [isControlsHovered, setIsControlsHovered] = useState(false);
|
|
35
|
+
const [showControls, setShowControls] = useState(false);
|
|
36
|
+
const [isSelectingText, setIsSelectingText] = useState(false);
|
|
37
|
+
const [showSettingsMenu, setShowSettingsMenu] = useState(false);
|
|
38
|
+
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
39
|
+
const settingsMenuRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const { helpers, state, dispatch } = useEditor();
|
|
41
|
+
const blockDefinition = blockRegistry.get(block.type);
|
|
42
|
+
|
|
43
|
+
// Check if this is a container block
|
|
44
|
+
const isContainer = isContainerBlock(block, blockRegistry);
|
|
45
|
+
// Get child blocks - if children are Block objects, use them directly
|
|
46
|
+
const childBlocks = isContainer && block.children && Array.isArray(block.children) && block.children.length > 0
|
|
47
|
+
? (typeof block.children[0] === 'object'
|
|
48
|
+
? block.children as Block[]
|
|
49
|
+
: getChildBlocks(block, state.blocks))
|
|
50
|
+
: [];
|
|
51
|
+
|
|
52
|
+
// Handle delayed hide with timeout
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const shouldShow = isHovered || isControlsHovered;
|
|
55
|
+
|
|
56
|
+
if (shouldShow) {
|
|
57
|
+
// Clear any pending hide timeout
|
|
58
|
+
if (hideTimeoutRef.current) {
|
|
59
|
+
clearTimeout(hideTimeoutRef.current);
|
|
60
|
+
hideTimeoutRef.current = null;
|
|
61
|
+
}
|
|
62
|
+
// Show immediately
|
|
63
|
+
setShowControls(true);
|
|
64
|
+
} else {
|
|
65
|
+
// Delay hiding by 500ms
|
|
66
|
+
hideTimeoutRef.current = setTimeout(() => {
|
|
67
|
+
setShowControls(false);
|
|
68
|
+
}, 500);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
if (hideTimeoutRef.current) {
|
|
73
|
+
clearTimeout(hideTimeoutRef.current);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}, [isHovered, isControlsHovered]);
|
|
77
|
+
|
|
78
|
+
if (!blockDefinition) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="p-4 border border-red-300 dark:border-red-700 rounded-2xl bg-red-50 dark:bg-red-900/20">
|
|
81
|
+
<p className="text-sm text-red-600 dark:text-red-400">
|
|
82
|
+
Unknown block type: {block.type}
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const EditComponent = blockDefinition.components.Edit;
|
|
89
|
+
|
|
90
|
+
const handleDragStart = (e: React.DragEvent) => {
|
|
91
|
+
// Check if user is selecting text - if so, prevent dragging
|
|
92
|
+
const selection = window.getSelection();
|
|
93
|
+
if (selection && selection.toString().length > 0) {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Prevent dragging if user was selecting text
|
|
99
|
+
if (isSelectingText) {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Only allow dragging when the block is focused/selected (hovered)
|
|
105
|
+
if (!isHovered && !showControls) {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Stop propagation to prevent parent containers from also handling the drag
|
|
111
|
+
e.stopPropagation();
|
|
112
|
+
|
|
113
|
+
console.log('[BlockWrapper] Drag Start:', {
|
|
114
|
+
blockId: block.id,
|
|
115
|
+
blockType: block.type,
|
|
116
|
+
blockData: block.data,
|
|
117
|
+
isContainer,
|
|
118
|
+
hasChildren: isContainer && childBlocks.length > 0,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
e.dataTransfer.setData('block-id', block.id);
|
|
122
|
+
e.dataTransfer.setData('block-type', block.type);
|
|
123
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
124
|
+
e.dataTransfer.setData('text/plain', ''); // Required for Firefox
|
|
125
|
+
|
|
126
|
+
// Store in a way that persists across components
|
|
127
|
+
if (typeof window !== 'undefined') {
|
|
128
|
+
(window as any).__DRAGGED_BLOCK_ID__ = block.id;
|
|
129
|
+
console.log('[BlockWrapper] Stored global dragged block ID:', block.id);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Track text selection
|
|
134
|
+
const handleMouseDown = (e: React.MouseEvent) => {
|
|
135
|
+
// Check if clicking on an input, textarea, or contentEditable element
|
|
136
|
+
const target = e.target as HTMLElement;
|
|
137
|
+
const isEditableElement = target.tagName === 'INPUT' ||
|
|
138
|
+
target.tagName === 'TEXTAREA' ||
|
|
139
|
+
target.isContentEditable ||
|
|
140
|
+
target.closest('input, textarea, [contenteditable="true"]');
|
|
141
|
+
|
|
142
|
+
if (isEditableElement) {
|
|
143
|
+
setIsSelectingText(true);
|
|
144
|
+
// Reset after mouse up
|
|
145
|
+
const handleMouseUp = () => {
|
|
146
|
+
setTimeout(() => {
|
|
147
|
+
const selection = window.getSelection();
|
|
148
|
+
if (!selection || selection.toString().length === 0) {
|
|
149
|
+
setIsSelectingText(false);
|
|
150
|
+
}
|
|
151
|
+
}, 100);
|
|
152
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
153
|
+
};
|
|
154
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// For hero blocks, only show controls when hovering over the image container
|
|
159
|
+
const isHeroBlock = block.type === 'hero';
|
|
160
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
161
|
+
|
|
162
|
+
const handleMouseMove = (e: React.MouseEvent) => {
|
|
163
|
+
if (isHeroBlock) {
|
|
164
|
+
// For hero blocks, check if mouse is actually over the image container element
|
|
165
|
+
const target = e.target as HTMLElement;
|
|
166
|
+
// Check if we're over the image container (using data attribute for more reliable detection)
|
|
167
|
+
const imageContainer = target.closest('[data-hero-image-container]');
|
|
168
|
+
// Check if we're over an Image component (from plugin-images)
|
|
169
|
+
const imageElement = target.closest('[data-image-id]');
|
|
170
|
+
// Check if we're directly over an img tag
|
|
171
|
+
const isImgTag = target.tagName === 'IMG';
|
|
172
|
+
|
|
173
|
+
const isOverImage = !!(imageContainer || imageElement || isImgTag);
|
|
174
|
+
setIsHovered(isOverImage);
|
|
175
|
+
} else if (!isHeroBlock) {
|
|
176
|
+
setIsHovered(true);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const handleMouseEnter = (e: React.MouseEvent) => {
|
|
181
|
+
if (!isHeroBlock) {
|
|
182
|
+
setIsHovered(true);
|
|
183
|
+
} else {
|
|
184
|
+
// For hero blocks, check position on enter
|
|
185
|
+
handleMouseMove(e);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const handleMouseLeave = () => {
|
|
190
|
+
setIsHovered(false);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Close settings menu when clicking outside
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
function handleClickOutside(event: MouseEvent) {
|
|
196
|
+
if (settingsMenuRef.current && !settingsMenuRef.current.contains(event.target as Node)) {
|
|
197
|
+
setShowSettingsMenu(false);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (showSettingsMenu) {
|
|
202
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return () => {
|
|
206
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
207
|
+
};
|
|
208
|
+
}, [showSettingsMenu]);
|
|
209
|
+
|
|
210
|
+
// Generate a unique block ID
|
|
211
|
+
const generateBlockId = (): string => {
|
|
212
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
213
|
+
return crypto.randomUUID();
|
|
214
|
+
}
|
|
215
|
+
return `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Clone a block with new IDs (recursive for nested blocks)
|
|
219
|
+
const cloneBlock = (blockToClone: Block): Block => {
|
|
220
|
+
const cloned: Block = {
|
|
221
|
+
...blockToClone,
|
|
222
|
+
id: generateBlockId(),
|
|
223
|
+
data: { ...blockToClone.data },
|
|
224
|
+
meta: blockToClone.meta ? { ...blockToClone.meta } : undefined,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Handle children if they exist
|
|
228
|
+
if (blockToClone.children) {
|
|
229
|
+
if (Array.isArray(blockToClone.children) && blockToClone.children.length > 0) {
|
|
230
|
+
// Check if children are Block objects or IDs
|
|
231
|
+
if (typeof blockToClone.children[0] === 'object') {
|
|
232
|
+
cloned.children = (blockToClone.children as Block[]).map(cloneBlock);
|
|
233
|
+
} else {
|
|
234
|
+
// If children are IDs, we need to find and clone the actual blocks
|
|
235
|
+
const childBlocks = getChildBlocks(blockToClone, allBlocks.length > 0 ? allBlocks : state.blocks);
|
|
236
|
+
cloned.children = childBlocks.map(cloneBlock);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return cloned;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const handleCopy = () => {
|
|
245
|
+
const clonedBlock = cloneBlock(block);
|
|
246
|
+
// Store in localStorage for persistence across components
|
|
247
|
+
if (typeof window !== 'undefined') {
|
|
248
|
+
localStorage.setItem('__BLOG_EDITOR_COPIED_BLOCK__', JSON.stringify(clonedBlock));
|
|
249
|
+
}
|
|
250
|
+
setShowSettingsMenu(false);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Store block ID when hovering for paste context
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (isHovered || showControls) {
|
|
256
|
+
if (typeof window !== 'undefined') {
|
|
257
|
+
(window as any).__BLOG_EDITOR_HOVERED_BLOCK_ID__ = block.id;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}, [isHovered, showControls, block.id]);
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div
|
|
264
|
+
ref={wrapperRef}
|
|
265
|
+
className="group relative"
|
|
266
|
+
onMouseEnter={handleMouseEnter}
|
|
267
|
+
onMouseMove={isHeroBlock ? handleMouseMove : undefined}
|
|
268
|
+
onMouseLeave={handleMouseLeave}
|
|
269
|
+
onMouseDown={handleMouseDown}
|
|
270
|
+
draggable={isHovered || showControls}
|
|
271
|
+
onDragStart={handleDragStart}
|
|
272
|
+
data-block-wrapper
|
|
273
|
+
data-block-id={block.id}
|
|
274
|
+
>
|
|
275
|
+
{/* Left Margin Controls - Visible on Hover */}
|
|
276
|
+
<div
|
|
277
|
+
className={`absolute -left-16 top-1/2 -translate-y-1/2 flex flex-col gap-2 transition-all duration-200 z-20 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
278
|
+
}`}
|
|
279
|
+
onMouseEnter={() => setIsControlsHovered(true)}
|
|
280
|
+
onMouseLeave={() => setIsControlsHovered(false)}
|
|
281
|
+
>
|
|
282
|
+
{/* Add Block Above */}
|
|
283
|
+
<button
|
|
284
|
+
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-primary dark:hover:text-primary bg-white dark:bg-neutral-800 rounded-lg shadow-sm border border-neutral-200 dark:border-neutral-700 hover:border-primary dark:hover:border-primary/50 transition-colors"
|
|
285
|
+
title="Add block above"
|
|
286
|
+
>
|
|
287
|
+
<Plus size={14} />
|
|
288
|
+
</button>
|
|
289
|
+
|
|
290
|
+
{/* Move Up */}
|
|
291
|
+
{onMoveUp && (
|
|
292
|
+
<button
|
|
293
|
+
onClick={onMoveUp}
|
|
294
|
+
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white bg-white dark:bg-neutral-800 rounded-lg shadow-sm border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
|
|
295
|
+
title="Move up"
|
|
296
|
+
>
|
|
297
|
+
<ChevronUp size={14} />
|
|
298
|
+
</button>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{/* Move Down */}
|
|
302
|
+
{onMoveDown && (
|
|
303
|
+
<button
|
|
304
|
+
onClick={onMoveDown}
|
|
305
|
+
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white bg-white dark:bg-neutral-800 rounded-lg shadow-sm border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
|
|
306
|
+
title="Move down"
|
|
307
|
+
>
|
|
308
|
+
<ChevronDown size={14} />
|
|
309
|
+
</button>
|
|
310
|
+
)}
|
|
311
|
+
|
|
312
|
+
{/* Delete */}
|
|
313
|
+
<button
|
|
314
|
+
onClick={onDelete}
|
|
315
|
+
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-red-500 dark:hover:text-red-400 bg-white dark:bg-neutral-800 rounded-lg shadow-sm border border-neutral-200 dark:border-neutral-700 hover:border-red-500 dark:hover:border-red-500/50 transition-colors"
|
|
316
|
+
title="Delete block"
|
|
317
|
+
>
|
|
318
|
+
<Trash2 size={14} />
|
|
319
|
+
</button>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
{/* Block Header - Positioned above the component */}
|
|
323
|
+
<div
|
|
324
|
+
className={`mb-2 transition-all relative z-[100] ${isHovered || showControls || showSettingsMenu
|
|
325
|
+
? 'opacity-100 translate-y-0'
|
|
326
|
+
: 'opacity-0 -translate-y-2 pointer-events-none'
|
|
327
|
+
}`}
|
|
328
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
329
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
330
|
+
>
|
|
331
|
+
<div className="flex items-center justify-between px-2 py-1.5 rounded-lg backdrop-blur-sm border bg-neutral-50/95 dark:bg-neutral-800/95 border-neutral-200 dark:border-neutral-800">
|
|
332
|
+
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
333
|
+
<GripVertical
|
|
334
|
+
size={12}
|
|
335
|
+
className="cursor-grab active:cursor-grabbing shrink-0 text-neutral-400 dark:text-neutral-500"
|
|
336
|
+
/>
|
|
337
|
+
<span className="text-[10px] font-black uppercase tracking-wider shrink-0 text-neutral-500 dark:text-neutral-400">
|
|
338
|
+
{blockDefinition.name}
|
|
339
|
+
</span>
|
|
340
|
+
|
|
341
|
+
{/* Layout Block Settings - Inline in header */}
|
|
342
|
+
{block.type === 'section' && (
|
|
343
|
+
<div className="flex items-center gap-2 ml-4 flex-1 min-w-0">
|
|
344
|
+
<select
|
|
345
|
+
value={(block.data.padding as string) || 'md'}
|
|
346
|
+
onChange={(e) => onUpdate({ padding: e.target.value })}
|
|
347
|
+
onClick={(e) => e.stopPropagation()}
|
|
348
|
+
className="text-[10px] font-bold border px-2 py-1 rounded outline-none focus:border-primary transition-all bg-white dark:bg-neutral-900/50 border-neutral-300 dark:border-neutral-700 dark:text-neutral-100"
|
|
349
|
+
>
|
|
350
|
+
<option value="sm">Small</option>
|
|
351
|
+
<option value="md">Medium</option>
|
|
352
|
+
<option value="lg">Large</option>
|
|
353
|
+
</select>
|
|
354
|
+
<select
|
|
355
|
+
value={(block.data.background as string) || 'DEFAULT'}
|
|
356
|
+
onChange={(e) => onUpdate({ background: e.target.value })}
|
|
357
|
+
onClick={(e) => e.stopPropagation()}
|
|
358
|
+
className="text-[10px] font-bold border px-2 py-1 rounded outline-none focus:border-primary transition-all bg-white dark:bg-neutral-900/50 border-neutral-300 dark:border-neutral-700 dark:text-neutral-100"
|
|
359
|
+
>
|
|
360
|
+
<option value="DEFAULT">Default</option>
|
|
361
|
+
<option value="NEUTRAL">Neutral</option>
|
|
362
|
+
<option value="SAGE">Sage</option>
|
|
363
|
+
<option value="CREAM">Cream</option>
|
|
364
|
+
</select>
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
|
|
368
|
+
{block.type === 'columns' && (
|
|
369
|
+
<select
|
|
370
|
+
value={(block.data.layout as string) || '50-50'}
|
|
371
|
+
onChange={(e) => onUpdate({ layout: e.target.value })}
|
|
372
|
+
onClick={(e) => e.stopPropagation()}
|
|
373
|
+
className="text-[10px] font-bold bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 px-2 py-1 rounded outline-none focus:border-primary transition-all dark:text-neutral-100 ml-4"
|
|
374
|
+
>
|
|
375
|
+
<option value="50-50">50% / 50%</option>
|
|
376
|
+
<option value="33-66">33% / 66%</option>
|
|
377
|
+
<option value="66-33">66% / 33%</option>
|
|
378
|
+
<option value="25-25-25-25">25% / 25% / 25% / 25%</option>
|
|
379
|
+
<option value="25-75">25% / 75%</option>
|
|
380
|
+
<option value="75-25">75% / 25%</option>
|
|
381
|
+
</select>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
<div className="relative shrink-0 z-[200]" ref={settingsMenuRef}>
|
|
385
|
+
<button
|
|
386
|
+
onClick={(e) => {
|
|
387
|
+
e.stopPropagation();
|
|
388
|
+
setShowSettingsMenu(!showSettingsMenu);
|
|
389
|
+
}}
|
|
390
|
+
className="p-1 rounded transition-colors text-neutral-400 dark:text-neutral-500 hover:text-neutral-950 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 relative z-[200]"
|
|
391
|
+
title="Block settings"
|
|
392
|
+
>
|
|
393
|
+
<Settings2 size={12} />
|
|
394
|
+
</button>
|
|
395
|
+
|
|
396
|
+
{/* Settings Dropdown Menu */}
|
|
397
|
+
{showSettingsMenu && (
|
|
398
|
+
<div className="absolute right-0 top-full mt-1 w-40 bg-white dark:bg-neutral-900 border border-neutral-300 dark:border-neutral-700 rounded-lg shadow-xl z-[200] overflow-hidden">
|
|
399
|
+
<button
|
|
400
|
+
onClick={(e) => {
|
|
401
|
+
e.stopPropagation();
|
|
402
|
+
handleCopy();
|
|
403
|
+
}}
|
|
404
|
+
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-bold text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
|
405
|
+
>
|
|
406
|
+
<Copy size={14} />
|
|
407
|
+
<span>Copy</span>
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
{/* Block Content Wrapper - Always visible border, minimal padding */}
|
|
416
|
+
<div
|
|
417
|
+
className={`relative rounded-xl border-2 transition-all ${showControls
|
|
418
|
+
? 'border-primary/60 dark:border-primary/40 bg-primary/5 dark:bg-primary/10'
|
|
419
|
+
: 'border-neutral-200 dark:border-neutral-700 bg-transparent'
|
|
420
|
+
}`}
|
|
421
|
+
style={{ zIndex: 1 }}
|
|
422
|
+
>
|
|
423
|
+
{/* Edit Component - No padding, let the component control its own spacing */}
|
|
424
|
+
<div className="relative" style={{ userSelect: 'text' }}>
|
|
425
|
+
{/* For container blocks, pass child blocks and handlers */}
|
|
426
|
+
{isContainer && (block.type === 'section' || block.type === 'columns') ? (
|
|
427
|
+
<EditComponent
|
|
428
|
+
block={block}
|
|
429
|
+
onUpdate={onUpdate}
|
|
430
|
+
onDelete={onDelete}
|
|
431
|
+
isSelected={isHovered}
|
|
432
|
+
childBlocks={childBlocks}
|
|
433
|
+
onChildBlockAdd={(type, index, containerId) => {
|
|
434
|
+
helpers.addBlock(type, index, containerId || block.id);
|
|
435
|
+
}}
|
|
436
|
+
onChildBlockUpdate={(id, data, containerId) => {
|
|
437
|
+
helpers.updateBlock(id, data);
|
|
438
|
+
}}
|
|
439
|
+
onChildBlockDelete={(id, containerId) => {
|
|
440
|
+
helpers.deleteBlock(id);
|
|
441
|
+
}}
|
|
442
|
+
onChildBlockMove={(id, newIndex, containerId) => {
|
|
443
|
+
helpers.moveBlock(id, newIndex, containerId || block.id);
|
|
444
|
+
}}
|
|
445
|
+
/>
|
|
446
|
+
) : (
|
|
447
|
+
<EditComponent
|
|
448
|
+
block={block}
|
|
449
|
+
onUpdate={onUpdate}
|
|
450
|
+
onDelete={onDelete}
|
|
451
|
+
isSelected={isHovered}
|
|
452
|
+
/>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|