@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.
Files changed (75) hide show
  1. package/README.md +216 -0
  2. package/package.json +57 -0
  3. package/src/api/README.md +224 -0
  4. package/src/api/categories.ts +43 -0
  5. package/src/api/check-title.ts +60 -0
  6. package/src/api/handler.ts +419 -0
  7. package/src/api/index.ts +33 -0
  8. package/src/api/route.ts +116 -0
  9. package/src/api/router.ts +114 -0
  10. package/src/api-server.ts +11 -0
  11. package/src/config.ts +161 -0
  12. package/src/hooks/README.md +91 -0
  13. package/src/hooks/index.ts +8 -0
  14. package/src/hooks/useBlog.ts +85 -0
  15. package/src/hooks/useBlogs.ts +123 -0
  16. package/src/index.server.ts +12 -0
  17. package/src/index.tsx +354 -0
  18. package/src/init.tsx +72 -0
  19. package/src/lib/blocks/BlockRenderer.tsx +141 -0
  20. package/src/lib/blocks/index.ts +6 -0
  21. package/src/lib/index.ts +9 -0
  22. package/src/lib/layouts/blocks/ColumnsBlock.tsx +134 -0
  23. package/src/lib/layouts/blocks/SectionBlock.tsx +104 -0
  24. package/src/lib/layouts/blocks/index.ts +8 -0
  25. package/src/lib/layouts/index.ts +52 -0
  26. package/src/lib/layouts/registerLayoutBlocks.ts +59 -0
  27. package/src/lib/mappers/apiMapper.ts +223 -0
  28. package/src/lib/migration/index.ts +6 -0
  29. package/src/lib/migration/mapper.ts +140 -0
  30. package/src/lib/rich-text/RichTextEditor.tsx +826 -0
  31. package/src/lib/rich-text/RichTextPreview.tsx +210 -0
  32. package/src/lib/rich-text/index.ts +10 -0
  33. package/src/lib/utils/blockHelpers.ts +72 -0
  34. package/src/lib/utils/configValidation.ts +137 -0
  35. package/src/lib/utils/index.ts +8 -0
  36. package/src/lib/utils/slugify.ts +79 -0
  37. package/src/registry/BlockRegistry.ts +142 -0
  38. package/src/registry/index.ts +11 -0
  39. package/src/state/EditorContext.tsx +277 -0
  40. package/src/state/index.ts +8 -0
  41. package/src/state/reducer.ts +694 -0
  42. package/src/state/types.ts +160 -0
  43. package/src/types/block.ts +269 -0
  44. package/src/types/index.ts +15 -0
  45. package/src/types/post.ts +165 -0
  46. package/src/utils/README.md +75 -0
  47. package/src/utils/client.ts +122 -0
  48. package/src/utils/index.ts +9 -0
  49. package/src/views/CanvasEditor/BlockWrapper.tsx +459 -0
  50. package/src/views/CanvasEditor/CanvasEditorView.tsx +917 -0
  51. package/src/views/CanvasEditor/EditorBody.tsx +475 -0
  52. package/src/views/CanvasEditor/EditorHeader.tsx +179 -0
  53. package/src/views/CanvasEditor/LayoutContainer.tsx +494 -0
  54. package/src/views/CanvasEditor/SaveConfirmationModal.tsx +233 -0
  55. package/src/views/CanvasEditor/components/CustomBlockItem.tsx +92 -0
  56. package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +130 -0
  57. package/src/views/CanvasEditor/components/LibraryItem.tsx +80 -0
  58. package/src/views/CanvasEditor/components/PrivacySettingsSection.tsx +212 -0
  59. package/src/views/CanvasEditor/components/index.ts +17 -0
  60. package/src/views/CanvasEditor/index.ts +16 -0
  61. package/src/views/PostManager/EmptyState.tsx +42 -0
  62. package/src/views/PostManager/PostActionsMenu.tsx +112 -0
  63. package/src/views/PostManager/PostCards.tsx +192 -0
  64. package/src/views/PostManager/PostFilters.tsx +80 -0
  65. package/src/views/PostManager/PostManagerView.tsx +280 -0
  66. package/src/views/PostManager/PostStats.tsx +81 -0
  67. package/src/views/PostManager/PostTable.tsx +225 -0
  68. package/src/views/PostManager/index.ts +15 -0
  69. package/src/views/Preview/PreviewBridgeView.tsx +64 -0
  70. package/src/views/Preview/index.ts +7 -0
  71. package/src/views/README.md +82 -0
  72. package/src/views/Settings/SettingsView.tsx +298 -0
  73. package/src/views/Settings/index.ts +7 -0
  74. package/src/views/SlugSEO/SlugSEOManagerView.tsx +94 -0
  75. package/src/views/SlugSEO/index.ts +7 -0
@@ -0,0 +1,233 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { X, AlertTriangle, CheckCircle2 } from 'lucide-react';
6
+ import { motion, AnimatePresence } from 'framer-motion';
7
+
8
+ export interface SaveConfirmationModalProps {
9
+ isOpen: boolean;
10
+ onClose: () => void;
11
+ onConfirm: () => Promise<void>;
12
+ isSaving: boolean;
13
+ postTitle?: string;
14
+ isPublished?: boolean;
15
+ saveAsDraft?: boolean;
16
+ error?: string | null;
17
+ }
18
+
19
+ export function SaveConfirmationModal({
20
+ isOpen,
21
+ onClose,
22
+ onConfirm,
23
+ isSaving,
24
+ postTitle,
25
+ isPublished,
26
+ saveAsDraft = false,
27
+ error,
28
+ }: SaveConfirmationModalProps) {
29
+ const [mounted, setMounted] = useState(false);
30
+ const [showSuccess, setShowSuccess] = useState(false);
31
+ const [saveError, setSaveError] = useState<string | null>(null);
32
+
33
+ useEffect(() => {
34
+ setMounted(true);
35
+ }, []);
36
+
37
+ // Reset states when modal opens
38
+ useEffect(() => {
39
+ if (isOpen) {
40
+ setShowSuccess(false);
41
+ setSaveError(null);
42
+ }
43
+ }, [isOpen]);
44
+
45
+ // Update error state when error prop changes
46
+ useEffect(() => {
47
+ if (error) {
48
+ setSaveError(error);
49
+ setShowSuccess(false);
50
+ } else {
51
+ setSaveError(null);
52
+ }
53
+ }, [error]);
54
+
55
+ if (!mounted) return null;
56
+
57
+ const handleConfirm = async () => {
58
+ // Clear any previous errors
59
+ setSaveError(null);
60
+ try {
61
+ await onConfirm();
62
+ // Only show success if onConfirm completes without error
63
+ setShowSuccess(true);
64
+ // Close modal after showing success for 1.5 seconds
65
+ setTimeout(() => {
66
+ onClose();
67
+ setShowSuccess(false);
68
+ setSaveError(null);
69
+ }, 1500);
70
+ } catch (error: any) {
71
+ // Display error in modal
72
+ const errorMessage = error.message || 'Failed to save post. Please try again.';
73
+ setSaveError(errorMessage);
74
+ console.error('[SaveConfirmationModal] Save failed:', error);
75
+ }
76
+ };
77
+
78
+ const modalContent = (
79
+ <AnimatePresence>
80
+ {isOpen && (
81
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
82
+ {/* Backdrop */}
83
+ <motion.div
84
+ initial={{ opacity: 0 }}
85
+ animate={{ opacity: 1 }}
86
+ exit={{ opacity: 0 }}
87
+ onClick={onClose}
88
+ className="absolute inset-0 bg-black/60 backdrop-blur-md"
89
+ />
90
+
91
+ {/* Modal */}
92
+ <motion.div
93
+ initial={{ scale: 0.9, opacity: 0, y: 20 }}
94
+ animate={{ scale: 1, opacity: 1, y: 0 }}
95
+ exit={{ scale: 0.9, opacity: 0, y: 20 }}
96
+ onClick={(e) => e.stopPropagation()}
97
+ className="relative w-full font-sans max-w-md bg-dashboard-card rounded-[2.5rem] p-8 shadow-2xl border border-dashboard-border"
98
+ >
99
+ {/* Close Button */}
100
+ <button
101
+ onClick={onClose}
102
+ disabled={isSaving}
103
+ className="absolute top-6 right-6 text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
104
+ >
105
+ <X size={24} />
106
+ </button>
107
+
108
+ {/* Icon */}
109
+ <div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6 transition-all ${
110
+ saveError
111
+ ? 'bg-red-100 dark:bg-red-900/20'
112
+ : showSuccess
113
+ ? 'bg-green-100 dark:bg-green-900/20'
114
+ : 'bg-amber-100 dark:bg-amber-900/20'
115
+ }`}>
116
+ {saveError ? (
117
+ <AlertTriangle size={32} className="text-red-600 dark:text-red-400" />
118
+ ) : showSuccess ? (
119
+ <CheckCircle2 size={32} className="text-green-600 dark:text-green-400" />
120
+ ) : (
121
+ <AlertTriangle size={32} className="text-amber-600 dark:text-amber-400" />
122
+ )}
123
+ </div>
124
+
125
+ {/* Title */}
126
+ <h3 className="text-2xl font-black text-center mb-4 text-neutral-950 dark:text-white">
127
+ {saveError
128
+ ? 'Error'
129
+ : showSuccess
130
+ ? 'Success!'
131
+ : saveAsDraft
132
+ ? 'Save Draft'
133
+ : `Confirm ${isPublished ? 'Update' : 'Publish'}`
134
+ }
135
+ </h3>
136
+
137
+ {/* Message */}
138
+ <div className="text-center mb-8">
139
+ {saveError ? (
140
+ <div className="space-y-3">
141
+ <p className="text-red-700 dark:text-red-300 font-semibold">
142
+ {saveError}
143
+ </p>
144
+ <p className="text-sm text-neutral-600 dark:text-neutral-400">
145
+ Please fix the issues above and try again.
146
+ </p>
147
+ </div>
148
+ ) : showSuccess ? (
149
+ <p className="text-green-700 dark:text-green-300 font-semibold">
150
+ {saveAsDraft ? 'Draft saved successfully!' : 'Post saved successfully!'}
151
+ </p>
152
+ ) : (
153
+ <>
154
+ <p className="text-neutral-700 dark:text-neutral-300 mb-2">
155
+ {saveAsDraft
156
+ ? 'Save this post as a draft? You can continue editing and publish it later.'
157
+ : isPublished
158
+ ? 'You are about to update this post. Changes cannot be undone.'
159
+ : 'You are about to publish this post. This action cannot be undone.'
160
+ }
161
+ </p>
162
+ {postTitle && (
163
+ <p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
164
+ "{postTitle}"
165
+ </p>
166
+ )}
167
+ </>
168
+ )}
169
+ </div>
170
+
171
+ {/* Actions */}
172
+ {showSuccess ? (
173
+ <div className="flex justify-center">
174
+ <button
175
+ onClick={onClose}
176
+ className="px-6 py-3 rounded-full bg-green-600 text-white font-bold uppercase text-[10px] tracking-widest transition-all hover:bg-green-700 shadow-lg shadow-green-600/20"
177
+ >
178
+ Close
179
+ </button>
180
+ </div>
181
+ ) : saveError ? (
182
+ <div className="flex gap-4">
183
+ <button
184
+ onClick={onClose}
185
+ className="flex-1 px-6 py-3 rounded-full border-2 border-dashboard-border text-dashboard-text font-bold uppercase text-[10px] tracking-widest transition-all hover:bg-dashboard-bg"
186
+ >
187
+ Close
188
+ </button>
189
+ <button
190
+ onClick={handleConfirm}
191
+ disabled={isSaving}
192
+ className="flex-1 px-6 py-3 rounded-full bg-primary text-white font-bold uppercase text-[10px] tracking-widest transition-all hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/20"
193
+ >
194
+ {isSaving
195
+ ? 'Retrying...'
196
+ : 'Try Again'
197
+ }
198
+ </button>
199
+ </div>
200
+ ) : (
201
+ <div className="flex gap-4">
202
+ <button
203
+ onClick={onClose}
204
+ disabled={isSaving}
205
+ className="flex-1 px-6 py-3 rounded-full border-2 border-dashboard-border text-dashboard-text font-bold uppercase text-[10px] tracking-widest transition-all hover:bg-dashboard-bg disabled:opacity-50 disabled:cursor-not-allowed"
206
+ >
207
+ Cancel
208
+ </button>
209
+ <button
210
+ onClick={handleConfirm}
211
+ disabled={isSaving}
212
+ className="flex-1 px-6 py-3 rounded-full bg-primary text-white font-bold uppercase text-[10px] tracking-widest transition-all hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/20"
213
+ >
214
+ {isSaving
215
+ ? 'Saving...'
216
+ : saveAsDraft
217
+ ? 'Save Draft'
218
+ : isPublished
219
+ ? 'Update Post'
220
+ : 'Publish Post'
221
+ }
222
+ </button>
223
+ </div>
224
+ )}
225
+ </motion.div>
226
+ </div>
227
+ )}
228
+ </AnimatePresence>
229
+ );
230
+
231
+ return createPortal(modalContent, document.body);
232
+ }
233
+
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef } from 'react';
4
+ import { GripVertical } from 'lucide-react';
5
+
6
+ export interface CustomBlockItemProps {
7
+ blockType: string;
8
+ name: string;
9
+ description?: string;
10
+ icon: React.ReactNode;
11
+ onAddBlock?: (blockType: string) => void;
12
+ }
13
+
14
+ /**
15
+ * Custom Block Item Component
16
+ * Draggable custom block from client registry and clickable to add at bottom
17
+ */
18
+ export function CustomBlockItem({
19
+ blockType,
20
+ name,
21
+ description,
22
+ icon,
23
+ onAddBlock
24
+ }: CustomBlockItemProps) {
25
+ const [hasDragged, setHasDragged] = useState(false);
26
+ const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
27
+
28
+ const handleDragStart = (e: React.DragEvent) => {
29
+ e.dataTransfer.setData('block-type', blockType);
30
+ e.dataTransfer.effectAllowed = 'move';
31
+ setHasDragged(true); // Mark as dragged when drag starts
32
+ };
33
+
34
+ const handleMouseDown = (e: React.MouseEvent) => {
35
+ // Track mouse position on mousedown
36
+ mouseDownRef.current = { x: e.clientX, y: e.clientY };
37
+ setHasDragged(false); // Reset drag state
38
+ };
39
+
40
+ const handleMouseMove = (e: React.MouseEvent) => {
41
+ // If mouse moved more than 5px, consider it a drag
42
+ if (mouseDownRef.current) {
43
+ const dx = Math.abs(e.clientX - mouseDownRef.current.x);
44
+ const dy = Math.abs(e.clientY - mouseDownRef.current.y);
45
+ if (dx > 5 || dy > 5) {
46
+ setHasDragged(true);
47
+ }
48
+ }
49
+ };
50
+
51
+ const handleClick = (e: React.MouseEvent) => {
52
+ // Only add block if we didn't drag
53
+ if (!hasDragged && onAddBlock) {
54
+ e.preventDefault();
55
+ e.stopPropagation();
56
+ onAddBlock(blockType);
57
+ }
58
+ // Reset state
59
+ mouseDownRef.current = null;
60
+ setTimeout(() => setHasDragged(false), 100);
61
+ };
62
+
63
+ return (
64
+ <div
65
+ draggable
66
+ onDragStart={handleDragStart}
67
+ onMouseDown={handleMouseDown}
68
+ onMouseMove={handleMouseMove}
69
+ onClick={handleClick}
70
+ className="p-4 rounded-xl border border-dashboard-border bg-dashboard-bg hover:border-primary cursor-pointer transition-all group"
71
+ title={description}
72
+ >
73
+ <div className="flex items-center justify-between mb-2">
74
+ <div className="flex items-center gap-2">
75
+ <div className="text-neutral-500 dark:text-neutral-400 group-hover:text-primary dark:group-hover:text-primary transition-colors">
76
+ {icon}
77
+ </div>
78
+ <span className="text-[10px] font-bold uppercase tracking-wider text-neutral-700 dark:text-neutral-300 group-hover:text-neutral-950 dark:group-hover:text-white transition-colors">
79
+ {name}
80
+ </span>
81
+ </div>
82
+ <GripVertical size={12} className="text-neutral-400 dark:text-neutral-500 group-hover:text-neutral-600 dark:group-hover:text-neutral-400" />
83
+ </div>
84
+ {description && (
85
+ <p className="text-[9px] text-neutral-500 dark:text-neutral-400 leading-relaxed line-clamp-2">
86
+ {description}
87
+ </p>
88
+ )}
89
+ </div>
90
+ );
91
+ }
92
+
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { Image as ImageIcon, Plus, X } from 'lucide-react';
5
+ import NextImage from 'next/image';
6
+ import { ImagePicker } from '@jhits/plugin-images';
7
+ import type { ImageMetadata } from '@jhits/plugin-images';
8
+
9
+ export interface FeaturedImage {
10
+ id?: string;
11
+ src?: string;
12
+ alt?: string;
13
+ brightness?: number;
14
+ blur?: number;
15
+ }
16
+
17
+ export interface FeaturedMediaSectionProps {
18
+ featuredImage?: FeaturedImage;
19
+ onUpdate: (image: FeaturedImage | undefined) => void;
20
+ }
21
+
22
+ /**
23
+ * Featured Media Section Component
24
+ * Handles featured image selection using ImagePicker
25
+ */
26
+ export function FeaturedMediaSection({
27
+ featuredImage,
28
+ onUpdate,
29
+ }: FeaturedMediaSectionProps) {
30
+ const [showImagePicker, setShowImagePicker] = useState(false);
31
+
32
+ const imageSrc = featuredImage?.src
33
+ ? (featuredImage.src.startsWith('http') ? featuredImage.src : `/api/uploads/${featuredImage.src}`)
34
+ : null;
35
+ const brightness = featuredImage?.brightness ?? 100;
36
+ const blur = featuredImage?.blur ?? 0;
37
+
38
+ const handleImageChange = (image: ImageMetadata | null) => {
39
+ if (image) {
40
+ const isUploadedImage = image.url.startsWith('/api/uploads/');
41
+ const src = isUploadedImage ? image.filename : image.url;
42
+ onUpdate({
43
+ src,
44
+ alt: image.alt || image.filename,
45
+ brightness: featuredImage?.brightness ?? 100,
46
+ blur: featuredImage?.blur ?? 0,
47
+ });
48
+ } else {
49
+ onUpdate(undefined);
50
+ }
51
+ setShowImagePicker(false);
52
+ };
53
+
54
+ return (
55
+ <section>
56
+ <div className="flex items-center gap-3 mb-6">
57
+ <ImageIcon size={14} className="text-neutral-500 dark:text-neutral-400" />
58
+ <label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
59
+ Featured Media
60
+ </label>
61
+ </div>
62
+ {imageSrc ? (
63
+ <div className="relative group">
64
+ <div
65
+ className="relative aspect-[16/10] bg-dashboard-bg rounded-3xl overflow-hidden border border-dashboard-border cursor-pointer"
66
+ onClick={() => setShowImagePicker(true)}
67
+ onMouseEnter={(e) => e.currentTarget.classList.add('ring-2', 'ring-primary/50')}
68
+ onMouseLeave={(e) => e.currentTarget.classList.remove('ring-2', 'ring-primary/50')}
69
+ >
70
+ <NextImage
71
+ src={imageSrc}
72
+ alt={featuredImage?.alt || 'Featured image'}
73
+ fill
74
+ className="object-cover"
75
+ style={{
76
+ filter: `brightness(${brightness}%) blur(${blur}px)`
77
+ }}
78
+ />
79
+ <div className="absolute inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-[2px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
80
+ <div className="flex items-center gap-2 px-4 py-2.5 bg-dashboard-card text-dashboard-text rounded-full shadow-2xl border border-dashboard-border pointer-events-auto">
81
+ <ImageIcon size={16} className="text-primary" />
82
+ <span className="text-sm font-bold">Edit Image</span>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ <button
87
+ onClick={() => onUpdate(undefined)}
88
+ className="mt-2 text-[10px] text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 font-bold uppercase tracking-wider"
89
+ >
90
+ Remove Image
91
+ </button>
92
+ </div>
93
+ ) : (
94
+ <div
95
+ className="group relative aspect-[16/10] bg-dashboard-bg rounded-3xl border-2 border-dashed border-dashboard-border flex flex-col items-center justify-center text-neutral-400 dark:text-neutral-500 hover:bg-dashboard-card hover:border-primary cursor-pointer transition-all duration-300"
96
+ onClick={() => setShowImagePicker(true)}
97
+ >
98
+ <Plus size={24} strokeWidth={1} className="mb-3 group-hover:scale-110 transition-transform" />
99
+ <span className="text-[9px] font-black uppercase tracking-widest">Assign Image</span>
100
+ </div>
101
+ )}
102
+
103
+ {/* Image Picker Modal */}
104
+ {showImagePicker && (
105
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => setShowImagePicker(false)}>
106
+ <div className="bg-dashboard-card rounded-2xl w-full max-w-2xl mx-4 p-6 shadow-2xl max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
107
+ <div className="flex items-center justify-between mb-6">
108
+ <h3 className="text-lg font-bold text-neutral-900 dark:text-neutral-100">
109
+ Select Featured Image
110
+ </h3>
111
+ <button
112
+ onClick={() => setShowImagePicker(false)}
113
+ className="p-2 hover:bg-dashboard-bg rounded-lg transition-colors"
114
+ >
115
+ <X size={20} />
116
+ </button>
117
+ </div>
118
+ <ImagePicker
119
+ value={featuredImage?.src}
120
+ onChange={handleImageChange}
121
+ darkMode={false}
122
+ showEffects={true}
123
+ />
124
+ </div>
125
+ </div>
126
+ )}
127
+ </section>
128
+ );
129
+ }
130
+
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef } from 'react';
4
+
5
+ export interface LibraryItemProps {
6
+ icon: React.ReactNode;
7
+ label: string;
8
+ blockType: string;
9
+ description?: string;
10
+ onAddBlock?: (blockType: string) => void;
11
+ }
12
+
13
+ /**
14
+ * Reusable Library Item Component
15
+ * Makes blocks draggable from the library and clickable to add at bottom
16
+ */
17
+ export function LibraryItem({
18
+ icon,
19
+ label,
20
+ blockType,
21
+ description,
22
+ onAddBlock
23
+ }: LibraryItemProps) {
24
+ const [hasDragged, setHasDragged] = useState(false);
25
+ const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
26
+
27
+ const handleDragStart = (e: React.DragEvent) => {
28
+ e.dataTransfer.setData('block-type', blockType);
29
+ e.dataTransfer.effectAllowed = 'move';
30
+ setHasDragged(true); // Mark as dragged when drag starts
31
+ };
32
+
33
+ const handleMouseDown = (e: React.MouseEvent) => {
34
+ // Track mouse position on mousedown
35
+ mouseDownRef.current = { x: e.clientX, y: e.clientY };
36
+ setHasDragged(false); // Reset drag state
37
+ };
38
+
39
+ const handleMouseMove = (e: React.MouseEvent) => {
40
+ // If mouse moved more than 5px, consider it a drag
41
+ if (mouseDownRef.current) {
42
+ const dx = Math.abs(e.clientX - mouseDownRef.current.x);
43
+ const dy = Math.abs(e.clientY - mouseDownRef.current.y);
44
+ if (dx > 5 || dy > 5) {
45
+ setHasDragged(true);
46
+ }
47
+ }
48
+ };
49
+
50
+ const handleClick = (e: React.MouseEvent) => {
51
+ // Only add block if we didn't drag
52
+ if (!hasDragged && onAddBlock) {
53
+ e.preventDefault();
54
+ e.stopPropagation();
55
+ onAddBlock(blockType);
56
+ }
57
+ // Reset state
58
+ mouseDownRef.current = null;
59
+ setTimeout(() => setHasDragged(false), 100);
60
+ };
61
+
62
+ return (
63
+ <div
64
+ draggable
65
+ onDragStart={handleDragStart}
66
+ onMouseDown={handleMouseDown}
67
+ onMouseMove={handleMouseMove}
68
+ onClick={handleClick}
69
+ className="flex flex-col items-center justify-center p-5 rounded-2xl border border-dashboard-border bg-dashboard-card hover:border-primary hover:shadow-xl hover:shadow-primary/5 transition-all cursor-pointer group"
70
+ >
71
+ <div className="text-neutral-400 dark:text-neutral-500 group-hover:text-primary dark:group-hover:text-primary mb-3 transition-colors duration-300">
72
+ {React.cloneElement(icon as React.ReactElement, { strokeWidth: 1.5 } as any)}
73
+ </div>
74
+ <span className="text-[9px] font-black uppercase tracking-[0.15em] text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-950 dark:group-hover:text-white transition-colors">
75
+ {label}
76
+ </span>
77
+ </div>
78
+ );
79
+ }
80
+