@jhits/plugin-newsletter 0.0.6 → 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 +8 -9
  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,131 +0,0 @@
1
- 'use client';
2
-
3
- import React, { useEffect, useRef, useState } from 'react';
4
- import { Search } from 'lucide-react';
5
- import type { BlockTypeDefinition } from '../../../types/block';
6
-
7
- export interface SlashCommandMenuProps {
8
- /** Registered blocks to show in menu */
9
- blocks: BlockTypeDefinition[];
10
- /** Current search query */
11
- query: string;
12
- /** Selected index */
13
- selectedIndex: number;
14
- /** Callback when a block is selected - receives replaceBlockId if replacing */
15
- onSelect: (replaceBlockId?: string) => void;
16
- /** Position for the menu */
17
- position: { top: number; left: number } | null;
18
- /** Callback to close the menu */
19
- onClose: () => void;
20
- /** Block ID to replace (if replacing existing block) */
21
- replaceBlockId?: string;
22
- }
23
-
24
- export function SlashCommandMenu({
25
- blocks,
26
- query,
27
- selectedIndex,
28
- onSelect,
29
- position,
30
- onClose,
31
- replaceBlockId,
32
- }: SlashCommandMenuProps) {
33
- const menuRef = useRef<HTMLDivElement>(null);
34
-
35
- // Handle keyboard navigation
36
- useEffect(() => {
37
- const handleKeyDown = (e: KeyboardEvent) => {
38
- if (e.key === 'ArrowDown') {
39
- e.preventDefault();
40
- // Selection is handled by parent
41
- } else if (e.key === 'ArrowUp') {
42
- e.preventDefault();
43
- // Selection is handled by parent
44
- } else if (e.key === 'Enter' || e.key === 'Tab') {
45
- e.preventDefault();
46
- if (blocks[selectedIndex]) {
47
- onSelect(replaceBlockId);
48
- }
49
- } else if (e.key === 'Escape') {
50
- e.preventDefault();
51
- onClose();
52
- }
53
- };
54
-
55
- window.addEventListener('keydown', handleKeyDown);
56
- return () => window.removeEventListener('keydown', handleKeyDown);
57
- }, [blocks, selectedIndex, onSelect, onClose, replaceBlockId]);
58
-
59
- // Scroll selected item into view
60
- useEffect(() => {
61
- if (menuRef.current && selectedIndex >= 0) {
62
- const selectedElement = menuRef.current.children[selectedIndex] as HTMLElement;
63
- if (selectedElement) {
64
- selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
65
- }
66
- }
67
- }, [selectedIndex]);
68
-
69
- if (!position || blocks.length === 0) {
70
- return null;
71
- }
72
-
73
- return (
74
- <div
75
- ref={menuRef}
76
- className="fixed z-50 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-2xl overflow-hidden"
77
- style={{
78
- top: `${position.top}px`,
79
- left: `${position.left}px`,
80
- width: '280px',
81
- maxHeight: '320px',
82
- overflowY: 'auto',
83
- }}
84
- >
85
- <div className="p-2 border-b border-neutral-200 dark:border-neutral-700 flex items-center gap-2">
86
- <Search size={14} className="text-neutral-400" />
87
- <input
88
- type="text"
89
- value={query}
90
- readOnly
91
- placeholder="Search blocks..."
92
- className="flex-1 bg-transparent border-none outline-none text-sm text-neutral-600 dark:text-neutral-300 placeholder:text-neutral-400"
93
- />
94
- </div>
95
- <div className="py-1">
96
- {blocks.map((block, index) => {
97
- const IconComponent = block.icon || block.components.Icon;
98
- const isSelected = index === selectedIndex;
99
-
100
- return (
101
- <button
102
- key={block.type}
103
- onClick={() => onSelect(replaceBlockId)}
104
- className={`w-full px-3 py-2 flex items-center gap-3 text-left transition-colors ${
105
- isSelected
106
- ? 'bg-primary/10 text-primary dark:bg-primary/20'
107
- : 'hover:bg-neutral-100 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
108
- }`}
109
- >
110
- {IconComponent && (
111
- <div className={`flex-shrink-0 ${isSelected ? 'text-primary' : 'text-neutral-400'}`}>
112
- <IconComponent />
113
- </div>
114
- )}
115
- <div className="flex-1 min-w-0">
116
- <div className={`text-sm font-medium ${isSelected ? 'text-primary' : 'text-neutral-900 dark:text-neutral-100'}`}>
117
- {block.name}
118
- </div>
119
- {block.description && (
120
- <div className="text-xs text-neutral-500 dark:text-neutral-400 truncate">
121
- {block.description}
122
- </div>
123
- )}
124
- </div>
125
- </button>
126
- );
127
- })}
128
- </div>
129
- </div>
130
- );
131
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * Newsletter Editor Components Exports
3
- */
4
-
5
- export { ErrorBanner } from './ErrorBanner';
6
- export type { ErrorBannerProps } from './ErrorBanner';
7
- export { LibraryItem } from './LibraryItem';
8
- export type { LibraryItemProps } from './LibraryItem';
9
- export { CustomBlockItem } from './CustomBlockItem';
10
- export type { CustomBlockItemProps } from './CustomBlockItem';
11
- export { EditorLibrary } from './EditorLibrary';
12
- export type { EditorLibraryProps } from './EditorLibrary';
13
- export { EditorCanvas } from './EditorCanvas';
14
- export type { EditorCanvasProps } from './EditorCanvas';
15
- export { EditorSidebar } from './EditorSidebar';
16
- export type { EditorSidebarProps } from './EditorSidebar';
@@ -1,7 +0,0 @@
1
- /**
2
- * Newsletter Editor Hooks
3
- */
4
-
5
- export { useRegisteredBlocks } from './useRegisteredBlocks';
6
- export { useNewsletterLoader } from './useNewsletterLoader';
7
- export { useKeyboardShortcuts } from './useKeyboardShortcuts';
@@ -1,136 +0,0 @@
1
- import { useEffect } from 'react';
2
- import type { Block } from '../../../types/block';
3
- import type { EditorState } from '../../../state/types';
4
-
5
- // Generate a unique block ID
6
- function generateBlockId(): string {
7
- if (typeof crypto !== 'undefined' && crypto.randomUUID) {
8
- return crypto.randomUUID();
9
- }
10
- return `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
11
- }
12
-
13
- export function useKeyboardShortcuts(
14
- state: EditorState,
15
- dispatch: (action: any) => void,
16
- canUndo: boolean,
17
- canRedo: boolean,
18
- undo: () => void,
19
- redo: () => void
20
- ) {
21
- useEffect(() => {
22
- const handleKeyDown = (e: KeyboardEvent) => {
23
- // Don't handle shortcuts if user is typing in an input/textarea/contentEditable
24
- const target = e.target as HTMLElement;
25
- const isEditableElement = target.tagName === 'INPUT' ||
26
- target.tagName === 'TEXTAREA' ||
27
- target.isContentEditable ||
28
- target.closest('input, textarea, [contenteditable="true"]');
29
-
30
- // Check for Ctrl+Z / Cmd+Z (Undo)
31
- if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
32
- if (!isEditableElement) {
33
- e.preventDefault();
34
- e.stopPropagation();
35
- if (canUndo) {
36
- undo();
37
- }
38
- return;
39
- }
40
- }
41
-
42
- // Check for Ctrl+Shift+Z / Cmd+Shift+Z or Ctrl+Y / Cmd+Y (Redo)
43
- if (((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') || ((e.ctrlKey || e.metaKey) && e.key === 'y')) {
44
- if (!isEditableElement) {
45
- e.preventDefault();
46
- e.stopPropagation();
47
- if (canRedo) {
48
- redo();
49
- }
50
- return;
51
- }
52
- }
53
-
54
- // Check for Ctrl+V (Windows/Linux) or Cmd+V (Mac)
55
- if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
56
- // Don't paste if user is typing in an input/textarea/contentEditable
57
- if (isEditableElement) {
58
- return; // Let the browser handle paste in editable elements
59
- }
60
-
61
- // Check if there's a copied block
62
- if (typeof window !== 'undefined') {
63
- const copiedBlockJson = localStorage.getItem('__NEWSLETTER_EDITOR_COPIED_BLOCK__');
64
- if (copiedBlockJson) {
65
- try {
66
- e.preventDefault();
67
- e.stopPropagation();
68
-
69
- const copiedBlock = JSON.parse(copiedBlockJson) as Block;
70
-
71
- // Clone a block with new IDs (recursive for nested blocks)
72
- const cloneBlock = (blockToClone: Block): Block => {
73
- const cloned: Block = {
74
- ...blockToClone,
75
- id: generateBlockId(),
76
- data: { ...blockToClone.data },
77
- meta: blockToClone.meta ? { ...blockToClone.meta } : undefined,
78
- };
79
-
80
- // Handle children if they exist
81
- if (blockToClone.children) {
82
- if (Array.isArray(blockToClone.children) && blockToClone.children.length > 0) {
83
- if (typeof blockToClone.children[0] === 'object') {
84
- cloned.children = (blockToClone.children as Block[]).map(cloneBlock);
85
- } else {
86
- // If children are IDs, find and clone the actual blocks
87
- const allBlocks = state.blocks;
88
- const childIds = blockToClone.children as string[];
89
- const childBlocks = childIds
90
- .map((childId: string) => allBlocks.find(b => b.id === childId))
91
- .filter((b): b is Block => b !== undefined);
92
- cloned.children = childBlocks.map(cloneBlock);
93
- }
94
- }
95
- }
96
-
97
- return cloned;
98
- };
99
-
100
- const pastedBlock = cloneBlock(copiedBlock);
101
-
102
- // Find where to paste - use hovered block or selected block, or paste at end
103
- const hoveredBlockId = (window as any).__NEWSLETTER_EDITOR_HOVERED_BLOCK_ID__;
104
- const targetBlockId = hoveredBlockId || state.selectedBlockId;
105
-
106
- let pasteIndex: number | undefined;
107
- if (targetBlockId) {
108
- const targetIndex = state.blocks.findIndex(b => b.id === targetBlockId);
109
- if (targetIndex !== -1) {
110
- pasteIndex = targetIndex + 1;
111
- }
112
- }
113
-
114
- // Dispatch ADD_BLOCK with the full block structure
115
- dispatch({
116
- type: 'ADD_BLOCK',
117
- payload: {
118
- block: pastedBlock,
119
- index: pasteIndex,
120
- containerId: undefined
121
- }
122
- });
123
- } catch (error) {
124
- console.error('Failed to paste block:', error);
125
- }
126
- }
127
- }
128
- }
129
- };
130
-
131
- window.addEventListener('keydown', handleKeyDown);
132
- return () => {
133
- window.removeEventListener('keydown', handleKeyDown);
134
- };
135
- }, [state.blocks, state.selectedBlockId, dispatch, canUndo, canRedo, undo, redo]);
136
- }
@@ -1,34 +0,0 @@
1
- import { useEffect, useState } from 'react';
2
- import { Newsletter } from '../../../types/newsletter';
3
-
4
- export function useNewsletterLoader(
5
- newsletterSlug: string | undefined,
6
- currentNewsletterId: string | null,
7
- loadNewsletter: (newsletter: Newsletter) => void
8
- ) {
9
- const [isLoadingNewsletter, setIsLoadingNewsletter] = useState(false);
10
-
11
- useEffect(() => {
12
- if (newsletterSlug && !currentNewsletterId) {
13
- const loadNewsletterData = async () => {
14
- try {
15
- setIsLoadingNewsletter(true);
16
- const response = await fetch(`/api/plugin-newsletter/newsletters/${newsletterSlug}`);
17
- if (!response.ok) {
18
- throw new Error('Failed to load newsletter');
19
- }
20
- const newsletter: Newsletter = await response.json();
21
- loadNewsletter(newsletter);
22
- } catch (error) {
23
- console.error('Failed to load newsletter:', error);
24
- alert('Failed to load newsletter. Please try again.');
25
- } finally {
26
- setIsLoadingNewsletter(false);
27
- }
28
- };
29
- loadNewsletterData();
30
- }
31
- }, [newsletterSlug, currentNewsletterId, loadNewsletter]);
32
-
33
- return { isLoadingNewsletter };
34
- }
@@ -1,54 +0,0 @@
1
- import { useState, useEffect } from 'react';
2
- import { blockRegistry } from '../../../registry/BlockRegistry';
3
-
4
- export function useRegisteredBlocks() {
5
- const [registeredBlocks, setRegisteredBlocks] = useState(() => {
6
- const initial = blockRegistry.getAll();
7
- return initial;
8
- });
9
-
10
- // Watch for registry changes and update state
11
- useEffect(() => {
12
- // Check immediately
13
- const checkBlocks = () => {
14
- const currentBlocks = blockRegistry.getAll();
15
- const hasChanged = currentBlocks.length !== registeredBlocks.length ||
16
- currentBlocks.some((b, i) => b.type !== registeredBlocks[i]?.type) ||
17
- registeredBlocks.some((b, i) => b.type !== currentBlocks[i]?.type);
18
-
19
- if (hasChanged) {
20
- setRegisteredBlocks([...currentBlocks]);
21
- }
22
- };
23
-
24
- // Initial check
25
- checkBlocks();
26
-
27
- // Poll for registry changes (blocks are registered asynchronously in useEffect)
28
- let pollCount = 0;
29
- const interval = setInterval(() => {
30
- pollCount++;
31
- checkBlocks();
32
- // Stop polling after 5 seconds (25 checks at 200ms)
33
- if (pollCount > 25) {
34
- clearInterval(interval);
35
- }
36
- }, 200);
37
-
38
- // Also check after delays to catch initial registrations
39
- const timeouts = [
40
- setTimeout(checkBlocks, 50),
41
- setTimeout(checkBlocks, 100),
42
- setTimeout(checkBlocks, 300),
43
- setTimeout(checkBlocks, 500),
44
- setTimeout(checkBlocks, 1000),
45
- ];
46
-
47
- return () => {
48
- clearInterval(interval);
49
- timeouts.forEach(clearTimeout);
50
- };
51
- }, [registeredBlocks.length]);
52
-
53
- return registeredBlocks;
54
- }
@@ -1,106 +0,0 @@
1
- import { useState, useEffect, useCallback } from 'react';
2
- import type { BlockTypeDefinition } from '../../../types/block';
3
-
4
- export interface SlashCommandState {
5
- isOpen: boolean;
6
- query: string;
7
- selectedIndex: number;
8
- position: { top: number; left: number } | null;
9
- replaceBlockId?: string;
10
- }
11
-
12
- export function useSlashCommand(
13
- blocks: BlockTypeDefinition[],
14
- onSelectBlock: (blockType: string, replaceBlockId?: string) => void
15
- ) {
16
- const [state, setState] = useState<SlashCommandState>({
17
- isOpen: false,
18
- query: '',
19
- selectedIndex: 0,
20
- position: null,
21
- });
22
-
23
- // Filter blocks based on query
24
- const filteredBlocks = blocks.filter(block => {
25
- if (!state.query) return true;
26
- const lowerQuery = state.query.toLowerCase();
27
- const name = block.name.toLowerCase();
28
- const description = block.description?.toLowerCase() || '';
29
- const type = block.type.toLowerCase();
30
- return name.includes(lowerQuery) || description.includes(lowerQuery) || type.includes(lowerQuery);
31
- });
32
-
33
- const openMenu = useCallback((position: { top: number; left: number }, replaceBlockId?: string) => {
34
- setState({
35
- isOpen: true,
36
- query: '',
37
- selectedIndex: 0,
38
- position,
39
- replaceBlockId,
40
- });
41
- }, []);
42
-
43
- const closeMenu = useCallback(() => {
44
- setState({
45
- isOpen: false,
46
- query: '',
47
- selectedIndex: 0,
48
- position: null,
49
- replaceBlockId: undefined,
50
- });
51
- }, []);
52
-
53
- const updateQuery = useCallback((query: string) => {
54
- setState(prev => ({
55
- ...prev,
56
- query,
57
- selectedIndex: 0, // Reset selection when query changes
58
- }));
59
- }, []);
60
-
61
- const moveSelection = useCallback((direction: 'up' | 'down') => {
62
- setState(prev => {
63
- if (!prev.isOpen || filteredBlocks.length === 0) return prev;
64
-
65
- let newIndex = prev.selectedIndex;
66
- if (direction === 'down') {
67
- newIndex = (prev.selectedIndex + 1) % filteredBlocks.length;
68
- } else {
69
- newIndex = prev.selectedIndex === 0 ? filteredBlocks.length - 1 : prev.selectedIndex - 1;
70
- }
71
-
72
- return {
73
- ...prev,
74
- selectedIndex: newIndex,
75
- };
76
- });
77
- }, [filteredBlocks.length]);
78
-
79
- const selectCurrent = useCallback((replaceBlockId?: string) => {
80
- if (state.isOpen && filteredBlocks[state.selectedIndex]) {
81
- // Always use the replaceBlockId from state (set when menu was opened)
82
- // The parameter is ignored to ensure we use the correct block ID
83
- const blockIdToReplace = state.replaceBlockId;
84
- if (!blockIdToReplace) {
85
- console.warn('[useSlashCommand] No replaceBlockId in state when selecting block');
86
- return;
87
- }
88
- onSelectBlock(filteredBlocks[state.selectedIndex].type, blockIdToReplace);
89
- closeMenu();
90
- }
91
- }, [state.isOpen, state.selectedIndex, state.replaceBlockId, filteredBlocks, onSelectBlock, closeMenu]);
92
-
93
- return {
94
- isOpen: state.isOpen,
95
- query: state.query,
96
- selectedIndex: state.selectedIndex,
97
- position: state.position,
98
- replaceBlockId: state.replaceBlockId,
99
- filteredBlocks,
100
- openMenu,
101
- closeMenu,
102
- updateQuery,
103
- moveSelection,
104
- selectCurrent,
105
- };
106
- }
@@ -1,12 +0,0 @@
1
- /**
2
- * Newsletter Canvas Editor Exports
3
- */
4
-
5
- export { CanvasEditorView } from './CanvasEditorView';
6
- export type { CanvasEditorViewProps } from './CanvasEditorView';
7
- export { EditorBody } from './EditorBody';
8
- export type { EditorBodyProps } from './EditorBody';
9
- export { BlockWrapper } from './BlockWrapper';
10
- export type { BlockWrapperProps } from './BlockWrapper';
11
- export { EditorHeader } from './EditorHeader';
12
- export type { EditorHeaderProps } from './EditorHeader';
@@ -1,38 +0,0 @@
1
- /**
2
- * Newsletter Editor View
3
- * Block-based editor for creating and editing newsletters
4
- */
5
-
6
- 'use client';
7
-
8
- import React from 'react';
9
- import { CanvasEditorView } from './CanvasEditor';
10
-
11
- export interface NewsletterEditorViewProps {
12
- newsletterSlug?: string;
13
- siteId: string;
14
- locale: string;
15
- darkMode?: boolean;
16
- backgroundColors?: {
17
- light: string;
18
- dark?: string;
19
- };
20
- }
21
-
22
- export function NewsletterEditorView({
23
- newsletterSlug,
24
- siteId,
25
- locale,
26
- darkMode,
27
- backgroundColors
28
- }: NewsletterEditorViewProps) {
29
- return (
30
- <CanvasEditorView
31
- newsletterSlug={newsletterSlug}
32
- siteId={siteId}
33
- locale={locale}
34
- darkMode={darkMode}
35
- backgroundColors={backgroundColors}
36
- />
37
- );
38
- }