@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,134 @@
1
+ /**
2
+ * Columns Block
3
+ * Flex/grid container with configurable column layouts
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+ import { BlockEditProps, BlockPreviewProps } from '../../../types/block';
10
+ import { LayoutContainer } from '../../../views/CanvasEditor/LayoutContainer';
11
+ import { COLUMN_LAYOUTS, ColumnLayout } from '../index';
12
+ import { Block } from '../../../types/block';
13
+
14
+ /**
15
+ * Columns Block Edit Component
16
+ */
17
+ export const ColumnsEdit: React.FC<BlockEditProps & {
18
+ childBlocks: Block[];
19
+ onChildBlockAdd: (type: string, index: number, containerId: string) => void;
20
+ onChildBlockUpdate: (id: string, data: Partial<Block['data']>, containerId: string) => void;
21
+ onChildBlockDelete: (id: string, containerId: string) => void;
22
+ onChildBlockMove: (id: string, newIndex: number, containerId: string) => void;
23
+ }> = ({
24
+ block,
25
+ onUpdate,
26
+ isSelected,
27
+ childBlocks = [],
28
+ onChildBlockAdd,
29
+ onChildBlockUpdate,
30
+ onChildBlockDelete,
31
+ onChildBlockMove,
32
+ }) => {
33
+ const layout = (block.data.layout as ColumnLayout) || '50-50';
34
+ const layoutConfig = COLUMN_LAYOUTS[layout];
35
+ const numColumns = layoutConfig.widths.length;
36
+
37
+ // Split child blocks into columns based on columnIndex in meta, or round-robin
38
+ const columns: Block[][] = Array.from({ length: numColumns }, () => []);
39
+ childBlocks.forEach((childBlock) => {
40
+ const columnIndex = childBlock.meta?.columnIndex;
41
+ if (typeof columnIndex === 'number' && columnIndex >= 0 && columnIndex < numColumns) {
42
+ columns[columnIndex].push(childBlock);
43
+ } else {
44
+ // Fallback to round-robin if no columnIndex specified
45
+ const index = childBlocks.indexOf(childBlock);
46
+ columns[index % numColumns].push(childBlock);
47
+ }
48
+ });
49
+
50
+ return (
51
+ <div className="rounded-xl bg-white">
52
+ {/* Column Grid */}
53
+ <div className={`grid ${layoutConfig.grid} gap-8 p-6`}>
54
+ {Array.from({ length: numColumns }).map((_, colIndex) => (
55
+ <div
56
+ key={colIndex}
57
+ className={`min-h-[200px] rounded-xl border border-dashed transition-all ${isSelected
58
+ ? 'border-primary/20'
59
+ : 'border-gray-200/50'
60
+ }`}
61
+ >
62
+ <div className="p-4">
63
+ <div className="mb-3 flex items-center justify-between">
64
+ <span className="text-[10px] font-black uppercase tracking-widest text-gray-400">
65
+ Column {colIndex + 1}
66
+ </span>
67
+ <span className="text-[9px] text-gray-500">
68
+ {layoutConfig.widths[colIndex]}%
69
+ </span>
70
+ </div>
71
+
72
+ <LayoutContainer
73
+ blocks={columns[colIndex] || []}
74
+ containerId={`${block.id}-col-${colIndex}`}
75
+ onBlockAdd={onChildBlockAdd}
76
+ onBlockUpdate={onChildBlockUpdate}
77
+ onBlockDelete={onChildBlockDelete}
78
+ onBlockMove={onChildBlockMove}
79
+ emptyLabel={`Drop blocks in column ${colIndex + 1}`}
80
+ />
81
+ </div>
82
+ </div>
83
+ ))}
84
+ </div>
85
+ </div>
86
+ );
87
+ };
88
+
89
+ /**
90
+ * Columns Block Preview Component
91
+ */
92
+ export const ColumnsPreview: React.FC<BlockPreviewProps & {
93
+ childBlocks?: Block[];
94
+ renderChild?: (block: Block) => React.ReactNode;
95
+ }> = ({ block, childBlocks = [], renderChild, context }) => {
96
+ const layout = (block.data.layout as ColumnLayout) || '50-50';
97
+ const layoutConfig = COLUMN_LAYOUTS[layout];
98
+ const numColumns = layoutConfig.widths.length;
99
+
100
+ // If childBlocks are provided, use them; otherwise get from block.children
101
+ const children = childBlocks.length > 0
102
+ ? childBlocks
103
+ : (block.children && Array.isArray(block.children) && typeof block.children[0] === 'object'
104
+ ? block.children as Block[]
105
+ : []);
106
+
107
+ // Split child blocks into columns based on columnIndex in meta, or round-robin
108
+ const columns: Block[][] = Array.from({ length: numColumns }, () => []);
109
+ children.forEach((childBlock) => {
110
+ const columnIndex = childBlock.meta?.columnIndex;
111
+ if (typeof columnIndex === 'number' && columnIndex >= 0 && columnIndex < numColumns) {
112
+ columns[columnIndex].push(childBlock);
113
+ } else {
114
+ // Fallback to round-robin if no columnIndex specified
115
+ const index = children.indexOf(childBlock);
116
+ columns[index % numColumns].push(childBlock);
117
+ }
118
+ });
119
+
120
+ return (
121
+ <div className={`grid ${layoutConfig.grid} gap-8 my-8`}>
122
+ {Array.from({ length: numColumns }).map((_, colIndex) => (
123
+ <div key={colIndex} className="min-h-[100px]">
124
+ {columns[colIndex]?.map((childBlock) => (
125
+ <React.Fragment key={childBlock.id}>
126
+ {renderChild ? renderChild(childBlock) : null}
127
+ </React.Fragment>
128
+ ))}
129
+ </div>
130
+ ))}
131
+ </div>
132
+ );
133
+ };
134
+
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Section Block
3
+ * Full-width wrapper with configurable padding and background
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+ import { BlockEditProps, BlockPreviewProps } from '../../../types/block';
10
+ import { LayoutContainer } from '../../../views/CanvasEditor/LayoutContainer';
11
+ import { LAYOUT_CONSTANTS, LAYOUT_BACKGROUNDS } from '../index';
12
+ import { Block } from '../../../types/block';
13
+
14
+ /**
15
+ * Section Block Edit Component
16
+ */
17
+ export const SectionEdit: React.FC<BlockEditProps & {
18
+ childBlocks: Block[];
19
+ onChildBlockAdd: (type: string, index: number, containerId: string) => void;
20
+ onChildBlockUpdate: (id: string, data: Partial<Block['data']>, containerId: string) => void;
21
+ onChildBlockDelete: (id: string, containerId: string) => void;
22
+ onChildBlockMove: (id: string, newIndex: number, containerId: string) => void;
23
+ }> = ({
24
+ block,
25
+ onUpdate,
26
+ isSelected,
27
+ childBlocks = [],
28
+ onChildBlockAdd,
29
+ onChildBlockUpdate,
30
+ onChildBlockDelete,
31
+ onChildBlockMove,
32
+ }) => {
33
+ const background = (block.data.background as keyof typeof LAYOUT_BACKGROUNDS) || 'DEFAULT';
34
+
35
+ return (
36
+ <div
37
+ className={`rounded-xl transition-all ${isSelected
38
+ ? 'bg-primary/5'
39
+ : ''
40
+ } ${LAYOUT_BACKGROUNDS[background]}`}
41
+ onDragStart={(e) => {
42
+ // Prevent section from being dragged when dragging nested blocks
43
+ // Check if the drag started on a nested block wrapper
44
+ const nestedBlockWrapper = (e.target as HTMLElement).closest('[data-block-wrapper]');
45
+ if (nestedBlockWrapper) {
46
+ const nestedBlockId = nestedBlockWrapper.getAttribute('data-block-id');
47
+ // If dragging a nested block, prevent the section's drag handler from firing
48
+ if (nestedBlockId && nestedBlockId !== block.id) {
49
+ e.stopPropagation();
50
+ e.preventDefault();
51
+ console.log('[SectionBlock] Preventing section drag, nested block is being dragged:', nestedBlockId);
52
+ }
53
+ }
54
+ }}
55
+ >
56
+ {/* Nested Content */}
57
+ <div className={`px-8 py-4`}>
58
+ <LayoutContainer
59
+ blocks={childBlocks}
60
+ containerId={block.id}
61
+ onBlockAdd={onChildBlockAdd}
62
+ onBlockUpdate={onChildBlockUpdate}
63
+ onBlockDelete={onChildBlockDelete}
64
+ onBlockMove={onChildBlockMove}
65
+ emptyLabel="Drop blocks into section"
66
+ />
67
+ </div>
68
+ </div>
69
+ );
70
+ };
71
+
72
+ /**
73
+ * Section Block Preview Component
74
+ */
75
+ export const SectionPreview: React.FC<BlockPreviewProps & {
76
+ childBlocks?: Block[];
77
+ renderChild?: (block: Block) => React.ReactNode;
78
+ }> = ({ block, childBlocks = [], renderChild, context }) => {
79
+ const background = (block.data.background as keyof typeof LAYOUT_BACKGROUNDS) || 'DEFAULT';
80
+
81
+ // If childBlocks are provided, use them; otherwise get from block.children
82
+ const children = childBlocks.length > 0
83
+ ? childBlocks
84
+ : (block.children && Array.isArray(block.children) && typeof block.children[0] === 'object'
85
+ ? block.children as Block[]
86
+ : []);
87
+
88
+ return (
89
+ <section className={`w-full ${LAYOUT_BACKGROUNDS[background]}`}>
90
+ <div className={`max-w-7xl mx-auto px-6 py-2`}>
91
+ {children.length > 0 && renderChild ? (
92
+ children.map((childBlock) => (
93
+ <React.Fragment key={childBlock.id}>
94
+ {renderChild(childBlock)}
95
+ </React.Fragment>
96
+ ))
97
+ ) : (
98
+ <div className="text-gray-400 text-sm italic">Empty section</div>
99
+ )}
100
+ </div>
101
+ </section>
102
+ );
103
+ };
104
+
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Core Layout Blocks
3
+ * Section and Columns blocks for the Universal Layout System
4
+ */
5
+
6
+ export { SectionEdit, SectionPreview } from './SectionBlock';
7
+ export { ColumnsEdit, ColumnsPreview } from './ColumnsBlock';
8
+
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Layout System Constants
3
+ * Standardized spacing and styling for layout blocks
4
+ */
5
+
6
+ // Spacing Constants (Earth-tone aligned)
7
+ export const LAYOUT_CONSTANTS = {
8
+ GUTTER: '2rem', // 32px - Space between columns
9
+ SPACING: '4rem', // 64px - Vertical padding for sections
10
+ SPACING_SM: '2rem', // 32px - Smaller vertical padding
11
+ SPACING_LG: '6rem', // 96px - Larger vertical padding
12
+ BORDER_RADIUS: '2rem', // 32px - Consistent rounded corners
13
+ } as const;
14
+
15
+ // Background Colors (Light mode only - matches client website theme)
16
+ export const LAYOUT_BACKGROUNDS = {
17
+ DEFAULT: 'bg-white',
18
+ NEUTRAL: 'bg-neutral-50',
19
+ SAGE: 'bg-primary/5',
20
+ CREAM: 'bg-amber-50/50',
21
+ } as const;
22
+
23
+ // Column Layout Presets
24
+ export type ColumnLayout = '50-50' | '33-66' | '66-33' | '25-25-25-25' | '25-75' | '75-25';
25
+
26
+ export const COLUMN_LAYOUTS: Record<ColumnLayout, { grid: string; widths: number[] }> = {
27
+ '50-50': {
28
+ grid: 'grid-cols-2',
29
+ widths: [50, 50],
30
+ },
31
+ '33-66': {
32
+ grid: 'grid-cols-3',
33
+ widths: [33, 66],
34
+ },
35
+ '66-33': {
36
+ grid: 'grid-cols-3',
37
+ widths: [66, 33],
38
+ },
39
+ '25-25-25-25': {
40
+ grid: 'grid-cols-4',
41
+ widths: [25, 25, 25, 25],
42
+ },
43
+ '25-75': {
44
+ grid: 'grid-cols-4',
45
+ widths: [25, 75],
46
+ },
47
+ '75-25': {
48
+ grid: 'grid-cols-4',
49
+ widths: [75, 25],
50
+ },
51
+ };
52
+
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Register Core Layout Blocks
3
+ * Registers Section and Columns blocks in the block registry
4
+ */
5
+
6
+ import { blockRegistry } from '../../registry/BlockRegistry';
7
+ import { SectionEdit, SectionPreview } from './blocks/SectionBlock';
8
+ import { ColumnsEdit, ColumnsPreview } from './blocks/ColumnsBlock';
9
+ import { Columns, Square } from 'lucide-react';
10
+
11
+ /**
12
+ * Register all core layout blocks
13
+ */
14
+ export function registerLayoutBlocks() {
15
+ // Section Block
16
+ blockRegistry.register({
17
+ type: 'section',
18
+ name: 'Section',
19
+ description: 'Full-width wrapper with configurable padding and background',
20
+ icon: Square,
21
+ defaultData: {
22
+ padding: 'md',
23
+ background: 'DEFAULT',
24
+ },
25
+ category: 'layout',
26
+ isContainer: true,
27
+ validate: (data) => {
28
+ return ['sm', 'md', 'lg'].includes(data.padding as string) &&
29
+ ['DEFAULT', 'NEUTRAL', 'SAGE', 'CREAM'].includes(data.background as string);
30
+ },
31
+ components: {
32
+ Edit: SectionEdit as any,
33
+ Preview: SectionPreview as any,
34
+ Icon: Square,
35
+ },
36
+ });
37
+
38
+ // Columns Block
39
+ blockRegistry.register({
40
+ type: 'columns',
41
+ name: 'Columns',
42
+ description: 'Flex/grid container with configurable column layouts (50/50, 33/66, etc.)',
43
+ icon: Columns,
44
+ defaultData: {
45
+ layout: '50-50',
46
+ },
47
+ category: 'layout',
48
+ isContainer: true,
49
+ validate: (data) => {
50
+ return ['50-50', '33-66', '66-33', '25-25-25-25', '25-75', '75-25'].includes(data.layout as string);
51
+ },
52
+ components: {
53
+ Edit: ColumnsEdit as any,
54
+ Preview: ColumnsPreview as any,
55
+ Icon: Columns,
56
+ },
57
+ });
58
+ }
59
+
@@ -0,0 +1,223 @@
1
+ /**
2
+ * API Mapper
3
+ * Converts between API format (MongoDB) and BlogPost format
4
+ */
5
+
6
+ import { BlogPost, PostStatus, SEOMetadata, PostMetadata } from '../../types/post';
7
+ import { Block } from '../../types/block';
8
+
9
+ /**
10
+ * API Blog Document Format (from MongoDB)
11
+ */
12
+ export interface APIBlogDocument {
13
+ _id?: string;
14
+ id?: string;
15
+ title: string;
16
+ slug: string;
17
+ contentBlocks?: Block[]; // New block-based format
18
+ content?: any[]; // Legacy format
19
+ summary?: string;
20
+ image?: {
21
+ src?: string;
22
+ alt?: string;
23
+ brightness?: number;
24
+ blur?: number;
25
+ };
26
+ categoryTags?: {
27
+ category?: string;
28
+ tags?: string[];
29
+ };
30
+ publicationData?: {
31
+ status?: PostStatus | 'concept'; // API uses 'concept' instead of 'draft'
32
+ date?: string | Date;
33
+ };
34
+ seo?: {
35
+ title?: string;
36
+ description?: string;
37
+ keywords?: string[];
38
+ ogImage?: string;
39
+ canonicalUrl?: string;
40
+ };
41
+ authorId?: string;
42
+ createdAt?: string | Date;
43
+ updatedAt?: string | Date;
44
+ }
45
+
46
+ /**
47
+ * Convert API document to BlogPost format
48
+ */
49
+ export function apiToBlogPost(doc: APIBlogDocument): BlogPost {
50
+ const id = doc._id?.toString() || doc.id || '';
51
+
52
+ // Use contentBlocks if available, otherwise fallback to content (legacy)
53
+ const blocks = doc.contentBlocks || [];
54
+
55
+ // Convert publication data
56
+ const publicationDate = doc.publicationData?.date
57
+ ? (typeof doc.publicationData.date === 'string'
58
+ ? doc.publicationData.date
59
+ : doc.publicationData.date.toISOString())
60
+ : undefined;
61
+
62
+ // Convert SEO data
63
+ const seo: SEOMetadata = {
64
+ title: doc.seo?.title,
65
+ description: doc.seo?.description,
66
+ keywords: doc.seo?.keywords,
67
+ ogImage: doc.seo?.ogImage,
68
+ canonicalUrl: doc.seo?.canonicalUrl,
69
+ };
70
+
71
+ // Convert metadata
72
+ const metadata: PostMetadata = {
73
+ featuredImage: doc.image ? {
74
+ src: doc.image.src,
75
+ alt: doc.image.alt,
76
+ brightness: doc.image.brightness,
77
+ blur: doc.image.blur,
78
+ } : undefined,
79
+ categories: doc.categoryTags?.category ? [doc.categoryTags.category] : [],
80
+ tags: doc.categoryTags?.tags || [],
81
+ excerpt: doc.summary,
82
+ privacy: undefined, // Privacy settings not in API yet
83
+ };
84
+
85
+ // Convert publication data - API uses 'concept' but we use 'draft'
86
+ const apiStatus = doc.publicationData?.status || 'concept';
87
+ const normalizedStatus = apiStatus === 'concept' ? 'draft' : apiStatus;
88
+
89
+ const publication = {
90
+ status: normalizedStatus as PostStatus,
91
+ date: publicationDate,
92
+ authorId: doc.authorId,
93
+ updatedAt: doc.updatedAt
94
+ ? (typeof doc.updatedAt === 'string' ? doc.updatedAt : doc.updatedAt.toISOString())
95
+ : undefined,
96
+ };
97
+
98
+ return {
99
+ id,
100
+ title: doc.title,
101
+ slug: doc.slug,
102
+ blocks,
103
+ seo,
104
+ publication,
105
+ metadata,
106
+ createdAt: doc.createdAt
107
+ ? (typeof doc.createdAt === 'string' ? doc.createdAt : doc.createdAt.toISOString())
108
+ : new Date().toISOString(),
109
+ updatedAt: doc.updatedAt
110
+ ? (typeof doc.updatedAt === 'string' ? doc.updatedAt : doc.updatedAt.toISOString())
111
+ : new Date().toISOString(),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Convert BlogPost to API document format
117
+ */
118
+ export function blogPostToAPI(post: BlogPost, authorId?: string): Partial<APIBlogDocument> {
119
+ return {
120
+ title: post.title,
121
+ slug: post.slug,
122
+ contentBlocks: post.blocks, // Use new block format
123
+ summary: post.metadata.excerpt,
124
+ image: post.metadata.featuredImage ? {
125
+ src: post.metadata.featuredImage.src,
126
+ alt: post.metadata.featuredImage.alt,
127
+ brightness: post.metadata.featuredImage.brightness,
128
+ blur: post.metadata.featuredImage.blur,
129
+ } : undefined,
130
+ categoryTags: {
131
+ category: post.metadata.categories?.[0] || '',
132
+ tags: post.metadata.tags || [],
133
+ },
134
+ publicationData: {
135
+ // API uses 'concept' instead of 'draft'
136
+ status: post.publication.status === 'draft' ? 'concept' : post.publication.status,
137
+ date: post.publication.date ? new Date(post.publication.date) : new Date(),
138
+ },
139
+ seo: {
140
+ title: post.seo.title,
141
+ description: post.seo.description,
142
+ keywords: post.seo.keywords,
143
+ ogImage: post.seo.ogImage,
144
+ canonicalUrl: post.seo.canonicalUrl,
145
+ },
146
+ authorId: authorId || post.publication.authorId,
147
+ updatedAt: new Date(),
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Convert EditorState to API format for saving
153
+ */
154
+ export function editorStateToAPI(state: {
155
+ title: string;
156
+ slug: string;
157
+ blocks: Block[];
158
+ seo: SEOMetadata;
159
+ metadata: PostMetadata;
160
+ status: PostStatus;
161
+ postId?: string | null;
162
+ }, authorId?: string): Partial<APIBlogDocument> {
163
+ // Map status: draft -> concept, published -> published, everything else stays as-is
164
+ const apiStatus = state.status === 'draft' ? 'concept' : state.status;
165
+
166
+ console.log('[editorStateToAPI] Mapping status:', {
167
+ editorStatus: state.status,
168
+ apiStatus: apiStatus,
169
+ willBePublished: apiStatus === 'published'
170
+ });
171
+
172
+ // Try to get category from metadata first, then check hero block
173
+ let category: string | undefined = undefined;
174
+ if (state.metadata.categories && state.metadata.categories.length > 0 && state.metadata.categories[0]?.trim()) {
175
+ category = state.metadata.categories[0].trim();
176
+ } else {
177
+ // Check hero block for category
178
+ const heroBlock = state.blocks.find(block => block.type === 'hero');
179
+ if (heroBlock && heroBlock.data && typeof heroBlock.data === 'object') {
180
+ const heroCategory = (heroBlock.data as any).category;
181
+ if (heroCategory && typeof heroCategory === 'string' && heroCategory.trim()) {
182
+ category = heroCategory.trim();
183
+ }
184
+ }
185
+ }
186
+
187
+ console.log('[editorStateToAPI] Category resolution:', {
188
+ fromMetadata: state.metadata.categories?.[0],
189
+ fromHeroBlock: state.blocks.find(b => b.type === 'hero')?.data ? (state.blocks.find(b => b.type === 'hero')!.data as any).category : undefined,
190
+ finalCategory: category
191
+ });
192
+
193
+ return {
194
+ title: state.title,
195
+ slug: state.slug,
196
+ contentBlocks: state.blocks,
197
+ summary: state.metadata.excerpt,
198
+ image: state.metadata.featuredImage ? {
199
+ src: state.metadata.featuredImage.src,
200
+ alt: state.metadata.featuredImage.alt,
201
+ brightness: state.metadata.featuredImage.brightness,
202
+ blur: state.metadata.featuredImage.blur,
203
+ } : undefined,
204
+ categoryTags: {
205
+ category: category,
206
+ tags: state.metadata.tags || [],
207
+ },
208
+ publicationData: {
209
+ status: apiStatus,
210
+ date: new Date(),
211
+ },
212
+ seo: {
213
+ title: state.seo.title,
214
+ description: state.seo.description,
215
+ keywords: state.seo.keywords,
216
+ ogImage: state.seo.ogImage,
217
+ canonicalUrl: state.seo.canonicalUrl,
218
+ },
219
+ authorId,
220
+ updatedAt: new Date(),
221
+ };
222
+ }
223
+
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Migration utilities exports
3
+ */
4
+
5
+ export * from './mapper';
6
+