@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,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,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
|
+
|