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