@jhits/plugin-newsletter 0.0.7 → 0.0.8

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 (45) hide show
  1. package/package.json +2 -3
  2. package/src/api/handler.ts +0 -693
  3. package/src/api/router.ts +0 -111
  4. package/src/index.server.ts +0 -12
  5. package/src/index.tsx +0 -313
  6. package/src/index.tsx.patch +0 -98
  7. package/src/init.tsx +0 -72
  8. package/src/lib/blocks/BlockRenderer.tsx +0 -125
  9. package/src/lib/email/EmailRenderer.tsx +0 -425
  10. package/src/lib/email/index.ts +0 -6
  11. package/src/lib/mappers/apiMapper.ts +0 -57
  12. package/src/lib/utils/blockHelpers.ts +0 -71
  13. package/src/lib/utils/slugify.ts +0 -43
  14. package/src/registry/BlockRegistry.ts +0 -53
  15. package/src/registry/index.ts +0 -5
  16. package/src/state/EditorContext.tsx +0 -279
  17. package/src/state/index.ts +0 -10
  18. package/src/state/reducer.ts +0 -561
  19. package/src/state/types.ts +0 -154
  20. package/src/types/block.ts +0 -275
  21. package/src/types/newsletter.ts +0 -151
  22. package/src/types/registry.ts +0 -14
  23. package/src/views/CanvasEditor/BlockWrapper.tsx +0 -143
  24. package/src/views/CanvasEditor/CanvasEditorView.tsx +0 -249
  25. package/src/views/CanvasEditor/EditorBody.tsx +0 -95
  26. package/src/views/CanvasEditor/EditorHeader.tsx +0 -139
  27. package/src/views/CanvasEditor/components/CustomBlockItem.tsx +0 -83
  28. package/src/views/CanvasEditor/components/EditorCanvas.tsx +0 -674
  29. package/src/views/CanvasEditor/components/EditorLibrary.tsx +0 -120
  30. package/src/views/CanvasEditor/components/EditorSidebar.tsx +0 -156
  31. package/src/views/CanvasEditor/components/ErrorBanner.tsx +0 -31
  32. package/src/views/CanvasEditor/components/LibraryItem.tsx +0 -71
  33. package/src/views/CanvasEditor/components/SlashCommandDetector.tsx +0 -196
  34. package/src/views/CanvasEditor/components/SlashCommandMenu.tsx +0 -131
  35. package/src/views/CanvasEditor/components/index.ts +0 -16
  36. package/src/views/CanvasEditor/hooks/index.ts +0 -7
  37. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +0 -136
  38. package/src/views/CanvasEditor/hooks/useNewsletterLoader.ts +0 -34
  39. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +0 -54
  40. package/src/views/CanvasEditor/hooks/useSlashCommand.ts +0 -106
  41. package/src/views/CanvasEditor/index.ts +0 -12
  42. package/src/views/NewsletterEditor.tsx +0 -38
  43. package/src/views/NewsletterManager.tsx +0 -240
  44. package/src/views/SettingsView.tsx +0 -216
  45. package/src/views/SubscribersView.tsx +0 -269
@@ -1,249 +0,0 @@
1
- 'use client';
2
-
3
- import React, { useState, useEffect, useRef, useCallback } from 'react';
4
- import { useEditor } from '../../state/EditorContext';
5
- import { EditorHeader } from './EditorHeader';
6
- import { ErrorBanner } from './components/ErrorBanner';
7
- import { EditorCanvas } from './components/EditorCanvas';
8
- import { EditorSidebar } from './components/EditorSidebar';
9
- import { SlashCommandMenu } from './components/SlashCommandMenu';
10
- import { useSlashCommand } from './hooks/useSlashCommand';
11
- import { useNewsletterLoader, useRegisteredBlocks, useKeyboardShortcuts } from './hooks';
12
- import { blockRegistry } from '../../registry';
13
- import type { Block } from '../../types/block';
14
- import type { NewsletterMetadata } from '../../types/newsletter';
15
-
16
- export interface CanvasEditorViewProps {
17
- newsletterSlug?: string;
18
- siteId: string;
19
- locale: string;
20
- /** Enable dark mode for content area and wrappers (default: true) */
21
- darkMode?: boolean;
22
- /** Background colors for the editor */
23
- backgroundColors?: {
24
- light: string;
25
- dark?: string;
26
- };
27
- }
28
-
29
- export function CanvasEditorView({ newsletterSlug, darkMode, backgroundColors: propsBackgroundColors, siteId, locale }: CanvasEditorViewProps) {
30
- const { state, helpers, dispatch, darkMode: contextDarkMode, backgroundColors: contextBackgroundColors, canUndo, canRedo } = useEditor();
31
- const effectiveDarkMode = darkMode !== undefined ? darkMode : contextDarkMode;
32
- const effectiveBackgroundColors = propsBackgroundColors || contextBackgroundColors;
33
- const [isSidebarOpen, setSidebarOpen] = useState(true);
34
- const [isPreviewMode, setIsPreviewMode] = useState(false);
35
- const [isSaving, setIsSaving] = useState(false);
36
- const [saveError, setSaveError] = useState<string | null>(null);
37
-
38
- // Get registered blocks
39
- const registeredBlocks = useRegisteredBlocks();
40
-
41
- // Newsletter loading
42
- const { isLoadingNewsletter } = useNewsletterLoader(
43
- newsletterSlug,
44
- state.newsletterId,
45
- (newsletter) => {
46
- helpers.loadNewsletter(newsletter);
47
- setTimeout(() => {
48
- dispatch({ type: 'MARK_CLEAN' });
49
- }, 0);
50
- }
51
- );
52
-
53
- // Ensure at least one paragraph block exists when creating new newsletter
54
- useEffect(() => {
55
- if (!newsletterSlug && state.blocks.length === 0 && !isLoadingNewsletter) {
56
- helpers.addBlock('paragraph', 0, undefined);
57
- }
58
- }, [newsletterSlug, state.blocks.length, isLoadingNewsletter, helpers]);
59
-
60
- // Track if we just loaded a newsletter to prevent marking as dirty during cleanup
61
- const justLoadedRef = useRef(false);
62
- const previousIsLoadingRef = useRef<boolean>(false);
63
- const loadingCleanupTimerRef = useRef<NodeJS.Timeout | null>(null);
64
-
65
- // Mark when newsletter loading completes and ensure it stays clean after all effects
66
- useEffect(() => {
67
- const loadingJustFinished = previousIsLoadingRef.current && !isLoadingNewsletter && state.newsletterId;
68
-
69
- if (loadingJustFinished) {
70
- justLoadedRef.current = true;
71
-
72
- if (loadingCleanupTimerRef.current) {
73
- clearTimeout(loadingCleanupTimerRef.current);
74
- }
75
-
76
- requestAnimationFrame(() => {
77
- requestAnimationFrame(() => {
78
- loadingCleanupTimerRef.current = setTimeout(() => {
79
- console.log('[CanvasEditorView] Newsletter loading complete - ensuring clean state');
80
- dispatch({ type: 'MARK_CLEAN' });
81
- justLoadedRef.current = false;
82
- loadingCleanupTimerRef.current = null;
83
- }, 500);
84
- });
85
- });
86
- }
87
-
88
- previousIsLoadingRef.current = isLoadingNewsletter;
89
-
90
- return () => {
91
- if (loadingCleanupTimerRef.current) {
92
- clearTimeout(loadingCleanupTimerRef.current);
93
- loadingCleanupTimerRef.current = null;
94
- }
95
- };
96
- }, [isLoadingNewsletter, state.newsletterId, dispatch]);
97
-
98
- // Keyboard shortcuts
99
- useKeyboardShortcuts(state, dispatch, canUndo, canRedo, helpers.undo, helpers.redo);
100
-
101
- // Slash command handler - always replaces the current paragraph block
102
- const handleSlashCommandSelect = useCallback((blockType: string, replaceBlockId?: string) => {
103
- if (replaceBlockId) {
104
- // Replace existing block (the paragraph that triggered the slash command)
105
- const blockIndex = state.blocks.findIndex(b => b.id === replaceBlockId);
106
- if (blockIndex === -1) {
107
- console.warn(`[CanvasEditorView] Block with ID "${replaceBlockId}" not found. Available blocks:`, state.blocks.map(b => b.id));
108
- return;
109
- }
110
-
111
- const blockDefinition = blockRegistry.get(blockType);
112
- if (!blockDefinition) {
113
- console.warn(`[CanvasEditorView] Block type "${blockType}" not found in registry`);
114
- return;
115
- }
116
-
117
- // Replace the block in place by updating the blocks array directly
118
- // This preserves the position and ID
119
- const newBlocks = state.blocks.map((block, idx) => {
120
- if (idx === blockIndex) {
121
- // Replace this block with the new type
122
- return {
123
- ...block,
124
- type: blockType,
125
- data: { ...blockDefinition.defaultData },
126
- };
127
- }
128
- // Keep all other blocks unchanged
129
- return block;
130
- });
131
- dispatch({ type: 'SET_BLOCKS', payload: newBlocks });
132
- } else {
133
- console.warn('[CanvasEditorView] No replaceBlockId provided to handleSlashCommandSelect');
134
- }
135
- }, [state.blocks, dispatch]);
136
-
137
- // Slash command hook
138
- const slashCommand = useSlashCommand(registeredBlocks, handleSlashCommandSelect);
139
-
140
- // Handle save
141
- const handleSave = async () => {
142
- setIsSaving(true);
143
- setSaveError(null);
144
- try {
145
- await helpers.save();
146
- setIsSaving(false);
147
- } catch (error: any) {
148
- console.error('[CanvasEditorView] Save error:', error);
149
- let errorMessage = error.message || 'Failed to save newsletter';
150
- if (errorMessage.includes('Unauthorized')) {
151
- errorMessage = 'You are not authorized to save. Please log in again.';
152
- }
153
- setSaveError(errorMessage);
154
- setIsSaving(false);
155
- throw error;
156
- }
157
- };
158
-
159
- if (isLoadingNewsletter) {
160
- return (
161
- <div className="h-full w-full bg-dashboard-card text-dashboard-text flex items-center justify-center">
162
- <div className="text-center">
163
- <div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin mx-auto mb-4" />
164
- <p className="text-sm text-neutral-500 dark:text-neutral-400">Loading newsletter...</p>
165
- </div>
166
- </div>
167
- );
168
- }
169
-
170
- return (
171
- <div className="h-full rounded-[2.5rem] w-full bg-dashboard-card text-dashboard-text flex flex-col font-sans transition-colors duration-300 overflow-hidden relative">
172
- <main className="flex flex-1 flex-col relative min-h-0">
173
- {/* Error Banner */}
174
- <ErrorBanner error={saveError} onDismiss={() => setSaveError(null)} />
175
-
176
- <EditorHeader
177
- isPreviewMode={isPreviewMode}
178
- onPreviewToggle={() => setIsPreviewMode(!isPreviewMode)}
179
- isSidebarOpen={isSidebarOpen}
180
- onSidebarToggle={() => setSidebarOpen(!isSidebarOpen)}
181
- isSaving={isSaving}
182
- onSave={handleSave}
183
- onSaveError={(error) => {
184
- if (error) {
185
- setSaveError(error);
186
- } else {
187
- setSaveError(null);
188
- }
189
- }}
190
- isDirty={state.isDirty}
191
- />
192
-
193
- {/* Editor Content Wrapper */}
194
- <div className="flex flex-1 relative overflow-hidden min-h-0 flex-nowrap">
195
- {/* CENTER: THE WRITING CANVAS */}
196
- <EditorCanvas
197
- isPreviewMode={isPreviewMode}
198
- contentBlocks={state.blocks}
199
- title={state.title}
200
- siteId={siteId}
201
- locale={locale}
202
- darkMode={effectiveDarkMode}
203
- backgroundColors={effectiveBackgroundColors}
204
- metadata={state.metadata}
205
- onTitleChange={(title: string) => dispatch({ type: 'SET_TITLE', payload: title })}
206
- onMetadataChange={(metadata) => dispatch({ type: 'SET_METADATA', payload: metadata })}
207
- onBlockAdd={(type: string, index: number, containerId?: string) => helpers.addBlock(type, index, containerId)}
208
- onBlockUpdate={(id: string, data: Partial<Block['data']>) => helpers.updateBlock(id, data)}
209
- onBlockDelete={(id: string) => helpers.deleteBlock(id)}
210
- onBlockMove={(id: string, newIndex: number, containerId?: string) => helpers.moveBlock(id, newIndex, containerId)}
211
- slashCommand={slashCommand}
212
- />
213
-
214
- {/* RIGHT SIDEBAR: THE "DESK" (SETTINGS) */}
215
- {!isPreviewMode && (
216
- <aside
217
- className={`transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] border-l border-dashboard-border bg-dashboard-sidebar overflow-y-auto overflow-x-hidden h-full ${isSidebarOpen ? 'w-80' : 'w-0 opacity-0 pointer-events-none'
218
- }`}
219
- >
220
- <EditorSidebar
221
- slug={state.slug}
222
- metadata={state.metadata}
223
- status={state.status}
224
- onMetadataUpdate={(metadata: Partial<NewsletterMetadata>) => dispatch({ type: 'SET_METADATA', payload: metadata })}
225
- />
226
- </aside>
227
- )}
228
- </div>
229
-
230
- {/* Slash Command Menu */}
231
- {slashCommand.isOpen && slashCommand.position && (
232
- <SlashCommandMenu
233
- blocks={slashCommand.filteredBlocks}
234
- query={slashCommand.query}
235
- selectedIndex={slashCommand.selectedIndex}
236
- onSelect={(replaceBlockId) => {
237
- // Use the replaceBlockId passed from the menu (which comes from state)
238
- // This ensures we use the correct block ID that was set when the menu opened
239
- slashCommand.selectCurrent(replaceBlockId);
240
- }}
241
- position={slashCommand.position}
242
- onClose={slashCommand.closeMenu}
243
- replaceBlockId={slashCommand.replaceBlockId}
244
- />
245
- )}
246
- </main>
247
- </div>
248
- );
249
- }
@@ -1,95 +0,0 @@
1
- /**
2
- * Editor Body Component
3
- * Simplified Notion-style editor with single paragraph by default
4
- */
5
-
6
- 'use client';
7
-
8
- import React, { useState, useRef } from 'react';
9
- import { Plus } from 'lucide-react';
10
- import { Block } from '../../types/block';
11
- import { BlockWrapper } from './BlockWrapper';
12
- import type { useSlashCommand } from './hooks/useSlashCommand';
13
-
14
- export interface EditorBodyProps {
15
- blocks: Block[];
16
- onBlockAdd: (type: string, index: number, containerId?: string) => void;
17
- onBlockUpdate: (id: string, data: Partial<Block['data']>) => void;
18
- onBlockDelete: (id: string) => void;
19
- onBlockMove: (id: string, newIndex: number, containerId?: string) => void;
20
- /** Enable dark mode for content area and wrappers (default: true) */
21
- darkMode?: boolean;
22
- /** Background colors for the editor */
23
- backgroundColors?: {
24
- light: string;
25
- dark?: string;
26
- };
27
- /** Slash command handler */
28
- slashCommand?: ReturnType<typeof useSlashCommand>;
29
- }
30
-
31
- export function EditorBody({
32
- blocks,
33
- onBlockAdd,
34
- onBlockUpdate,
35
- onBlockDelete,
36
- onBlockMove,
37
- darkMode = true,
38
- backgroundColors,
39
- slashCommand,
40
- }: EditorBodyProps) {
41
- const handleDelete = (blockId: string, index: number) => {
42
- onBlockDelete(blockId);
43
- // If deleting the last block, ensure we still have at least one paragraph
44
- if (blocks.length === 1) {
45
- setTimeout(() => {
46
- onBlockAdd('paragraph', 0);
47
- }, 0);
48
- }
49
- };
50
-
51
- return (
52
- <div className="relative">
53
- {blocks.map((block, index) => (
54
- <React.Fragment key={block.id}>
55
- {/* Add Block Button Above */}
56
- <div className="relative h-2 group/add-button">
57
- <button
58
- onClick={() => onBlockAdd('paragraph', index)}
59
- className="absolute left-0 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center opacity-0 group-hover/add-button:opacity-100 transition-opacity hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded"
60
- title="Add block"
61
- >
62
- <Plus size={14} className="text-neutral-400 dark:text-neutral-500" />
63
- </button>
64
- </div>
65
-
66
- {/* Block */}
67
- <div className="relative">
68
- <BlockWrapper
69
- block={block}
70
- onUpdate={(data) => onBlockUpdate(block.id, data)}
71
- onDelete={() => handleDelete(block.id, index)}
72
- onMoveUp={index > 0 ? () => onBlockMove(block.id, index - 1) : undefined}
73
- onMoveDown={index < blocks.length - 1 ? () => onBlockMove(block.id, index + 1) : undefined}
74
- allBlocks={blocks}
75
- slashCommand={slashCommand}
76
- blockIndex={index}
77
- onAddBlockBelow={(blockType) => onBlockAdd(blockType, index + 1)}
78
- />
79
- </div>
80
- </React.Fragment>
81
- ))}
82
-
83
- {/* Add Block Button at Bottom */}
84
- <div className="relative h-8 group/add-button-bottom mt-2">
85
- <button
86
- onClick={() => onBlockAdd('paragraph', blocks.length)}
87
- className="absolute left-0 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center opacity-0 group-hover/add-button-bottom:opacity-100 transition-opacity hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded"
88
- title="Add block"
89
- >
90
- <Plus size={14} className="text-neutral-400 dark:text-neutral-500" />
91
- </button>
92
- </div>
93
- </div>
94
- );
95
- }
@@ -1,139 +0,0 @@
1
- 'use client';
2
-
3
- import React, { useState } from 'react';
4
- import { ArrowLeft, Settings2, Save, Edit, Eye } from 'lucide-react';
5
- import { useEditor } from '../../state/EditorContext';
6
-
7
- export interface EditorHeaderProps {
8
- isPreviewMode: boolean;
9
- onPreviewToggle: () => void;
10
- isSidebarOpen: boolean;
11
- onSidebarToggle: () => void;
12
- isSaving: boolean;
13
- onSave: () => Promise<void>;
14
- onSaveError: (error: string | null) => void;
15
- isDirty?: boolean;
16
- }
17
-
18
- export function EditorHeader({
19
- isPreviewMode,
20
- onPreviewToggle,
21
- isSidebarOpen,
22
- onSidebarToggle,
23
- isSaving,
24
- onSave,
25
- onSaveError,
26
- isDirty = false,
27
- }: EditorHeaderProps) {
28
- const { state } = useEditor();
29
- const [saveError, setSaveError] = useState<string | null>(null);
30
-
31
- const handleSave = async () => {
32
- try {
33
- setSaveError(null);
34
- await onSave();
35
- } catch (error: any) {
36
- console.error('[EditorHeader] Failed to save newsletter:', error);
37
- let errorMessage = error.message || 'Failed to save newsletter';
38
- if (errorMessage.includes('Unauthorized')) {
39
- errorMessage = 'You are not authorized to save. Please log in again.';
40
- }
41
- setSaveError(errorMessage);
42
- onSaveError(errorMessage);
43
- }
44
- };
45
-
46
- return (
47
- <header className="flex items-center justify-between px-6 py-3 bg-dashboard-sidebar backdrop-blur-md border-b border-dashboard-border flex-none shrink-0">
48
- <div className="flex items-center gap-6">
49
- <button
50
- onClick={() => {
51
- if (isDirty) {
52
- const confirmed = window.confirm(
53
- 'You have unsaved changes. Are you sure you want to leave? Your changes will be lost.'
54
- );
55
- if (!confirmed) {
56
- return;
57
- }
58
- }
59
- window.location.href = '/dashboard/newsletter';
60
- }}
61
- className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white transition-colors"
62
- >
63
- <ArrowLeft size={20} strokeWidth={1.5} />
64
- </button>
65
- </div>
66
-
67
- <div className="flex items-center gap-4">
68
- {/* Edit/Preview Toggle */}
69
- <div className="flex items-center bg-dashboard-bg border border-dashboard-border rounded-full p-1 gap-1">
70
- <button
71
- onClick={() => {
72
- if (isPreviewMode) {
73
- onPreviewToggle();
74
- }
75
- }}
76
- className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${
77
- !isPreviewMode
78
- ? 'bg-primary text-white shadow-sm'
79
- : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
80
- }`}
81
- title="Edit mode"
82
- >
83
- <Edit size={12} strokeWidth={2.5} />
84
- <span>Edit</span>
85
- </button>
86
- <button
87
- onClick={() => {
88
- if (!isPreviewMode) {
89
- onPreviewToggle();
90
- }
91
- }}
92
- className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${
93
- isPreviewMode
94
- ? 'bg-primary text-white shadow-sm'
95
- : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
96
- }`}
97
- title="Preview mode"
98
- >
99
- <Eye size={12} strokeWidth={2.5} />
100
- <span>Preview</span>
101
- </button>
102
- </div>
103
-
104
- {/* Settings Toggle */}
105
- <button
106
- onClick={onSidebarToggle}
107
- className={`flex items-center gap-2 text-[10px] uppercase tracking-widest font-black transition-all ${isSidebarOpen ? 'text-dashboard-text' : 'text-neutral-500 dark:text-neutral-400'
108
- }`}
109
- >
110
- <Settings2 size={16} strokeWidth={1.5} />
111
- Settings
112
- </button>
113
-
114
- {/* Save Button */}
115
- <button
116
- onClick={handleSave}
117
- disabled={isSaving || !isDirty}
118
- className={`inline-flex items-center gap-2 px-4 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-colors shadow-lg ${
119
- isSaving || !isDirty
120
- ? 'bg-neutral-400 text-white cursor-not-allowed'
121
- : 'bg-primary text-white hover:bg-primary/90'
122
- }`}
123
- >
124
- {isSaving ? (
125
- <>
126
- <div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
127
- Saving...
128
- </>
129
- ) : (
130
- <>
131
- <Save size={14} />
132
- {isDirty ? 'Save Changes' : 'Saved'}
133
- </>
134
- )}
135
- </button>
136
- </div>
137
- </header>
138
- );
139
- }
@@ -1,83 +0,0 @@
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
- export function CustomBlockItem({
15
- blockType,
16
- name,
17
- description,
18
- icon,
19
- onAddBlock
20
- }: CustomBlockItemProps) {
21
- const [hasDragged, setHasDragged] = useState(false);
22
- const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
23
-
24
- const handleDragStart = (e: React.DragEvent) => {
25
- e.dataTransfer.setData('block-type', blockType);
26
- e.dataTransfer.effectAllowed = 'move';
27
- setHasDragged(true);
28
- };
29
-
30
- const handleMouseDown = (e: React.MouseEvent) => {
31
- mouseDownRef.current = { x: e.clientX, y: e.clientY };
32
- setHasDragged(false);
33
- };
34
-
35
- const handleMouseMove = (e: React.MouseEvent) => {
36
- if (mouseDownRef.current) {
37
- const dx = Math.abs(e.clientX - mouseDownRef.current.x);
38
- const dy = Math.abs(e.clientY - mouseDownRef.current.y);
39
- if (dx > 5 || dy > 5) {
40
- setHasDragged(true);
41
- }
42
- }
43
- };
44
-
45
- const handleClick = (e: React.MouseEvent) => {
46
- if (!hasDragged && onAddBlock) {
47
- e.preventDefault();
48
- e.stopPropagation();
49
- onAddBlock(blockType);
50
- }
51
- mouseDownRef.current = null;
52
- setTimeout(() => setHasDragged(false), 100);
53
- };
54
-
55
- return (
56
- <div
57
- draggable
58
- onDragStart={handleDragStart}
59
- onMouseDown={handleMouseDown}
60
- onMouseMove={handleMouseMove}
61
- onClick={handleClick}
62
- className="p-4 rounded-xl border border-dashboard-border bg-dashboard-bg hover:border-primary cursor-pointer transition-all group"
63
- title={description}
64
- >
65
- <div className="flex items-center justify-between mb-2">
66
- <div className="flex items-center gap-2">
67
- <div className="text-neutral-500 dark:text-neutral-400 group-hover:text-primary dark:group-hover:text-primary transition-colors">
68
- {icon}
69
- </div>
70
- <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">
71
- {name}
72
- </span>
73
- </div>
74
- <GripVertical size={12} className="text-neutral-400 dark:text-neutral-500 group-hover:text-neutral-600 dark:group-hover:text-neutral-400" />
75
- </div>
76
- {description && (
77
- <p className="text-[9px] text-neutral-500 dark:text-neutral-400 leading-relaxed line-clamp-2">
78
- {description}
79
- </p>
80
- )}
81
- </div>
82
- );
83
- }