@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,212 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { Shield, Key, Users } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export interface PrivacySettings {
|
|
7
|
+
isPrivate?: boolean;
|
|
8
|
+
password?: string;
|
|
9
|
+
sharedWithUsers?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PrivacySettingsSectionProps {
|
|
13
|
+
privacy?: PrivacySettings;
|
|
14
|
+
onUpdate: (privacy: PrivacySettings) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Privacy Settings Section Component
|
|
19
|
+
* Handles privacy settings: private, password-protected, share with users
|
|
20
|
+
*/
|
|
21
|
+
export function PrivacySettingsSection({
|
|
22
|
+
privacy,
|
|
23
|
+
onUpdate,
|
|
24
|
+
}: PrivacySettingsSectionProps) {
|
|
25
|
+
const [users, setUsers] = useState<Array<{ _id: string; name: string; email: string }>>([]);
|
|
26
|
+
const [loadingUsers, setLoadingUsers] = useState(false);
|
|
27
|
+
const [showPasswordInput, setShowPasswordInput] = useState(false);
|
|
28
|
+
const [passwordValue, setPasswordValue] = useState(privacy?.password || '');
|
|
29
|
+
const [showUserSelector, setShowUserSelector] = useState(false);
|
|
30
|
+
|
|
31
|
+
// Fetch users from plugin-users
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const fetchUsers = async () => {
|
|
34
|
+
try {
|
|
35
|
+
setLoadingUsers(true);
|
|
36
|
+
const res = await fetch('/api/users');
|
|
37
|
+
const data = await res.json();
|
|
38
|
+
if (Array.isArray(data)) {
|
|
39
|
+
setUsers(data);
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error('Failed to load users', err);
|
|
43
|
+
} finally {
|
|
44
|
+
setLoadingUsers(false);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
fetchUsers();
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const handlePrivacyToggle = (isPrivate: boolean) => {
|
|
51
|
+
onUpdate({
|
|
52
|
+
...privacy,
|
|
53
|
+
isPrivate,
|
|
54
|
+
// Clear password and shared users if making public
|
|
55
|
+
...(isPrivate ? {} : { password: undefined, sharedWithUsers: undefined }),
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handlePasswordChange = (password: string) => {
|
|
60
|
+
setPasswordValue(password);
|
|
61
|
+
onUpdate({
|
|
62
|
+
...privacy,
|
|
63
|
+
password: password || undefined,
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleUserToggle = (userId: string) => {
|
|
68
|
+
const currentUsers = privacy?.sharedWithUsers || [];
|
|
69
|
+
const newUsers = currentUsers.includes(userId)
|
|
70
|
+
? currentUsers.filter(id => id !== userId)
|
|
71
|
+
: [...currentUsers, userId];
|
|
72
|
+
onUpdate({
|
|
73
|
+
...privacy,
|
|
74
|
+
sharedWithUsers: newUsers.length > 0 ? newUsers : undefined,
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
|
|
80
|
+
<div className="flex items-center gap-3 mb-6">
|
|
81
|
+
<Shield size={14} className="text-neutral-500 dark:text-neutral-400" />
|
|
82
|
+
<label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
|
|
83
|
+
Privacy Settings
|
|
84
|
+
</label>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="space-y-4">
|
|
87
|
+
{/* Private Toggle */}
|
|
88
|
+
<div className="flex items-center justify-between">
|
|
89
|
+
<div>
|
|
90
|
+
<label className="text-[10px] text-neutral-700 dark:text-neutral-300 font-bold block mb-1">
|
|
91
|
+
Make Private
|
|
92
|
+
</label>
|
|
93
|
+
<p className="text-[9px] text-neutral-500 dark:text-neutral-400">
|
|
94
|
+
Hide from public view
|
|
95
|
+
</p>
|
|
96
|
+
</div>
|
|
97
|
+
<button
|
|
98
|
+
onClick={() => handlePrivacyToggle(!privacy?.isPrivate)}
|
|
99
|
+
className={`relative w-12 h-6 rounded-full transition-colors ${privacy?.isPrivate ? 'bg-primary' : 'bg-neutral-300 dark:bg-neutral-700'
|
|
100
|
+
}`}
|
|
101
|
+
>
|
|
102
|
+
<div className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${privacy?.isPrivate ? 'translate-x-6' : 'translate-x-0'
|
|
103
|
+
}`} />
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Password Protection */}
|
|
108
|
+
{privacy?.isPrivate && (
|
|
109
|
+
<div className="space-y-2">
|
|
110
|
+
<div className="flex items-center justify-between">
|
|
111
|
+
<div>
|
|
112
|
+
<label className="text-[10px] text-neutral-700 dark:text-neutral-300 font-bold block mb-1">
|
|
113
|
+
Password Protection
|
|
114
|
+
</label>
|
|
115
|
+
<p className="text-[9px] text-neutral-500 dark:text-neutral-400">
|
|
116
|
+
Require password to view
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
<button
|
|
120
|
+
onClick={() => {
|
|
121
|
+
setShowPasswordInput(!showPasswordInput);
|
|
122
|
+
if (!showPasswordInput && !privacy.password) {
|
|
123
|
+
setPasswordValue('');
|
|
124
|
+
}
|
|
125
|
+
}}
|
|
126
|
+
className={`p-1.5 rounded-lg transition-colors ${privacy.password || showPasswordInput
|
|
127
|
+
? 'bg-primary/10 text-primary'
|
|
128
|
+
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-400'
|
|
129
|
+
}`}
|
|
130
|
+
>
|
|
131
|
+
<Key size={14} />
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
{(showPasswordInput || privacy.password) && (
|
|
135
|
+
<input
|
|
136
|
+
type="password"
|
|
137
|
+
value={passwordValue}
|
|
138
|
+
onChange={(e) => handlePasswordChange(e.target.value)}
|
|
139
|
+
placeholder="Enter password"
|
|
140
|
+
className="w-full px-3 py-2 text-xs bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 rounded-lg outline-none focus:border-primary transition-all dark:text-neutral-100"
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Share with Users */}
|
|
147
|
+
{privacy?.isPrivate && (
|
|
148
|
+
<div className="space-y-2">
|
|
149
|
+
<div className="flex items-center justify-between">
|
|
150
|
+
<div>
|
|
151
|
+
<label className="text-[10px] text-neutral-700 dark:text-neutral-300 font-bold block mb-1">
|
|
152
|
+
Share with Users
|
|
153
|
+
</label>
|
|
154
|
+
<p className="text-[9px] text-neutral-500 dark:text-neutral-400">
|
|
155
|
+
Grant access to specific users
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
<button
|
|
159
|
+
onClick={() => setShowUserSelector(!showUserSelector)}
|
|
160
|
+
className={`p-1.5 rounded-lg transition-colors ${(privacy.sharedWithUsers?.length || 0) > 0 || showUserSelector
|
|
161
|
+
? 'bg-primary/10 text-primary'
|
|
162
|
+
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-400'
|
|
163
|
+
}`}
|
|
164
|
+
>
|
|
165
|
+
<Users size={14} />
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
{showUserSelector && (
|
|
169
|
+
<div className="bg-dashboard-bg rounded-lg p-3 border border-dashboard-border max-h-48 overflow-y-auto">
|
|
170
|
+
{loadingUsers ? (
|
|
171
|
+
<p className="text-[10px] text-neutral-500 dark:text-neutral-400">Loading users...</p>
|
|
172
|
+
) : users.length === 0 ? (
|
|
173
|
+
<p className="text-[10px] text-neutral-500 dark:text-neutral-400">No users found</p>
|
|
174
|
+
) : (
|
|
175
|
+
<div className="space-y-2">
|
|
176
|
+
{users.map((user) => (
|
|
177
|
+
<label
|
|
178
|
+
key={user._id}
|
|
179
|
+
className="flex items-center gap-2 cursor-pointer hover:bg-dashboard-bg p-2 rounded transition-colors"
|
|
180
|
+
>
|
|
181
|
+
<input
|
|
182
|
+
type="checkbox"
|
|
183
|
+
checked={privacy.sharedWithUsers?.includes(user._id) || false}
|
|
184
|
+
onChange={() => handleUserToggle(user._id)}
|
|
185
|
+
className="rounded border-neutral-300 dark:border-neutral-700 text-primary focus:ring-primary"
|
|
186
|
+
/>
|
|
187
|
+
<div className="flex-1 min-w-0">
|
|
188
|
+
<p className="text-[10px] font-bold text-neutral-700 dark:text-neutral-300 truncate">
|
|
189
|
+
{user.name}
|
|
190
|
+
</p>
|
|
191
|
+
<p className="text-[9px] text-neutral-500 dark:text-neutral-400 truncate">
|
|
192
|
+
{user.email}
|
|
193
|
+
</p>
|
|
194
|
+
</div>
|
|
195
|
+
</label>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
{privacy.sharedWithUsers && privacy.sharedWithUsers.length > 0 && (
|
|
202
|
+
<p className="text-[9px] text-neutral-500 dark:text-neutral-400">
|
|
203
|
+
Shared with {privacy.sharedWithUsers.length} user{privacy.sharedWithUsers.length !== 1 ? 's' : ''}
|
|
204
|
+
</p>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
</section>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Editor Components
|
|
3
|
+
* Exports all components used in the Canvas Editor
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { LibraryItem } from './LibraryItem';
|
|
7
|
+
export type { LibraryItemProps } from './LibraryItem';
|
|
8
|
+
|
|
9
|
+
export { CustomBlockItem } from './CustomBlockItem';
|
|
10
|
+
export type { CustomBlockItemProps } from './CustomBlockItem';
|
|
11
|
+
|
|
12
|
+
export { FeaturedMediaSection } from './FeaturedMediaSection';
|
|
13
|
+
export type { FeaturedMediaSectionProps, FeaturedImage } from './FeaturedMediaSection';
|
|
14
|
+
|
|
15
|
+
export { PrivacySettingsSection } from './PrivacySettingsSection';
|
|
16
|
+
export type { PrivacySettingsSectionProps, PrivacySettings } from './PrivacySettingsSection';
|
|
17
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Editor View Exports
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { CanvasEditorView } from './CanvasEditorView';
|
|
6
|
+
export type { CanvasEditorViewProps } from './CanvasEditorView';
|
|
7
|
+
export { EditorBody } from './EditorBody';
|
|
8
|
+
export { LayoutContainer } from './LayoutContainer';
|
|
9
|
+
export type { LayoutContainerProps } from './LayoutContainer';
|
|
10
|
+
export type { EditorBodyProps } from './EditorBody';
|
|
11
|
+
export { BlockWrapper } from './BlockWrapper';
|
|
12
|
+
export type { BlockWrapperProps } from './BlockWrapper';
|
|
13
|
+
export { EditorHeader } from './EditorHeader';
|
|
14
|
+
export type { EditorHeaderProps } from './EditorHeader';
|
|
15
|
+
export { SaveConfirmationModal } from './SaveConfirmationModal';
|
|
16
|
+
export type { SaveConfirmationModalProps } from './SaveConfirmationModal';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Empty State Component
|
|
3
|
+
* Botanical-themed empty state for when no posts are found
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { Sprout, Plus } from 'lucide-react';
|
|
10
|
+
|
|
11
|
+
export interface EmptyStateProps {
|
|
12
|
+
hasFilters: boolean;
|
|
13
|
+
onCreatePost: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function EmptyState({ hasFilters, onCreatePost }: EmptyStateProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex flex-col items-center justify-center py-20 px-8 bg-neutral-100 dark:bg-neutral-800/50 rounded-[2.5rem] border-2 border-dashed border-neutral-300 dark:border-neutral-700">
|
|
19
|
+
<div className="w-24 h-24 rounded-full bg-green-500/10 dark:bg-green-500/20 flex items-center justify-center mb-6">
|
|
20
|
+
<Sprout className="text-green-600 dark:text-green-400 size-12" />
|
|
21
|
+
</div>
|
|
22
|
+
<h3 className="text-xl font-black text-neutral-950 dark:text-white uppercase tracking-tight mb-2">
|
|
23
|
+
{hasFilters ? 'No Posts Found' : 'No Posts Yet'}
|
|
24
|
+
</h3>
|
|
25
|
+
<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center mb-6 max-w-md">
|
|
26
|
+
{hasFilters
|
|
27
|
+
? 'Try adjusting your search or filter criteria to find what you\'re looking for.'
|
|
28
|
+
: 'Start growing your content garden. Create your first blog post to share your botanical knowledge with the world.'}
|
|
29
|
+
</p>
|
|
30
|
+
{!hasFilters && (
|
|
31
|
+
<button
|
|
32
|
+
onClick={onCreatePost}
|
|
33
|
+
className="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-full text-[10px] font-black uppercase tracking-widest hover:bg-primary/90 transition-all shadow-lg shadow-primary/20"
|
|
34
|
+
>
|
|
35
|
+
<Plus size={16} />
|
|
36
|
+
Create Your First Post
|
|
37
|
+
</button>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post Actions Menu Component
|
|
3
|
+
* Three-dot menu with actions for each post
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
9
|
+
import { createPortal } from 'react-dom';
|
|
10
|
+
import { MoreVertical, Edit, Eye, Copy, Trash2 } from 'lucide-react';
|
|
11
|
+
|
|
12
|
+
export interface PostActionsMenuProps {
|
|
13
|
+
onEdit: () => void;
|
|
14
|
+
onPreview: () => void;
|
|
15
|
+
onDuplicate: () => void;
|
|
16
|
+
onDelete: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function PostActionsMenu({
|
|
20
|
+
onEdit,
|
|
21
|
+
onPreview,
|
|
22
|
+
onDuplicate,
|
|
23
|
+
onDelete,
|
|
24
|
+
}: PostActionsMenuProps) {
|
|
25
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
26
|
+
const [menuPosition, setMenuPosition] = useState({ top: 0, right: 0 });
|
|
27
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
28
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
|
|
30
|
+
// Calculate menu position when opening
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (isOpen && buttonRef.current) {
|
|
33
|
+
const buttonRect = buttonRef.current.getBoundingClientRect();
|
|
34
|
+
setMenuPosition({
|
|
35
|
+
top: buttonRect.bottom + 8, // 8px = mt-2 equivalent
|
|
36
|
+
right: window.innerWidth - buttonRect.right,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}, [isOpen]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
function handleClickOutside(event: MouseEvent) {
|
|
43
|
+
if (
|
|
44
|
+
menuRef.current &&
|
|
45
|
+
!menuRef.current.contains(event.target as Node) &&
|
|
46
|
+
buttonRef.current &&
|
|
47
|
+
!buttonRef.current.contains(event.target as Node)
|
|
48
|
+
) {
|
|
49
|
+
setIsOpen(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (isOpen) {
|
|
54
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
59
|
+
};
|
|
60
|
+
}, [isOpen]);
|
|
61
|
+
|
|
62
|
+
const actions = [
|
|
63
|
+
{ label: 'Edit', icon: Edit, onClick: onEdit, color: 'text-neutral-600 dark:text-neutral-400' },
|
|
64
|
+
// { label: 'Preview', icon: Eye, onClick: onPreview, color: 'text-blue-600 dark:text-blue-400' },
|
|
65
|
+
{ label: 'Duplicate', icon: Copy, onClick: onDuplicate, color: 'text-neutral-600 dark:text-neutral-400' },
|
|
66
|
+
{ label: 'Delete', icon: Trash2, onClick: onDelete, color: 'text-red-600 dark:text-red-400' },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const menuContent = isOpen && (
|
|
70
|
+
<div
|
|
71
|
+
ref={menuRef}
|
|
72
|
+
className="fixed w-48 bg-dashboard-card border border-dashboard-border rounded-2xl shadow-xl z-[9999] overflow-hidden"
|
|
73
|
+
style={{
|
|
74
|
+
top: `${menuPosition.top}px`,
|
|
75
|
+
right: `${menuPosition.right}px`,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{actions.map((action) => {
|
|
79
|
+
const Icon = action.icon;
|
|
80
|
+
return (
|
|
81
|
+
<button
|
|
82
|
+
key={action.label}
|
|
83
|
+
onClick={() => {
|
|
84
|
+
action.onClick();
|
|
85
|
+
setIsOpen(false);
|
|
86
|
+
}}
|
|
87
|
+
className={`w-full flex items-center font-sans gap-3 px-4 py-3 text-sm hover:bg-dashboard-bg transition-colors ${action.color}`}
|
|
88
|
+
>
|
|
89
|
+
<Icon size={16} />
|
|
90
|
+
<span>{action.label}</span>
|
|
91
|
+
</button>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<button
|
|
100
|
+
ref={buttonRef}
|
|
101
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
102
|
+
className="p-2 text-neutral-400 hover:text-dashboard-text hover:bg-dashboard-bg rounded-full transition-colors"
|
|
103
|
+
title="Actions"
|
|
104
|
+
>
|
|
105
|
+
<MoreVertical size={18} />
|
|
106
|
+
</button>
|
|
107
|
+
|
|
108
|
+
{typeof window !== 'undefined' && isOpen && createPortal(menuContent, document.body)}
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post Cards Component
|
|
3
|
+
* Card-based layout for displaying posts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React, { useState, useEffect } from 'react';
|
|
9
|
+
import { Calendar, User, UserCheck } from 'lucide-react';
|
|
10
|
+
import { Image } from '@jhits/plugin-images';
|
|
11
|
+
import { PostListItem, PostStatus } from '../../types/post';
|
|
12
|
+
import { PostActionsMenu } from './PostActionsMenu';
|
|
13
|
+
import { useSession } from 'next-auth/react';
|
|
14
|
+
|
|
15
|
+
export interface PostCardsProps {
|
|
16
|
+
posts: PostListItem[];
|
|
17
|
+
locale: string;
|
|
18
|
+
onEdit: (postId: string) => void;
|
|
19
|
+
onPreview: (postId: string) => void;
|
|
20
|
+
onDuplicate: (postId: string) => void;
|
|
21
|
+
onDelete: (postId: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getStatusBadgeColor(status: PostStatus) {
|
|
25
|
+
switch (status) {
|
|
26
|
+
case 'published':
|
|
27
|
+
return 'bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20';
|
|
28
|
+
case 'draft':
|
|
29
|
+
return 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20';
|
|
30
|
+
case 'scheduled':
|
|
31
|
+
return 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20';
|
|
32
|
+
case 'archived':
|
|
33
|
+
return 'bg-neutral-500/10 text-neutral-700 dark:text-neutral-400 border-neutral-500/20';
|
|
34
|
+
default:
|
|
35
|
+
return 'bg-neutral-500/10 text-neutral-700 dark:text-neutral-400 border-neutral-500/20';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatDate(dateString: string | undefined, locale: string) {
|
|
40
|
+
if (!dateString) return 'No date';
|
|
41
|
+
return new Date(dateString).toLocaleDateString(locale, {
|
|
42
|
+
day: 'numeric',
|
|
43
|
+
month: 'short',
|
|
44
|
+
year: 'numeric',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function PostCards({
|
|
49
|
+
posts,
|
|
50
|
+
locale,
|
|
51
|
+
onEdit,
|
|
52
|
+
onPreview,
|
|
53
|
+
onDuplicate,
|
|
54
|
+
onDelete,
|
|
55
|
+
}: PostCardsProps) {
|
|
56
|
+
const { data: session, status: sessionStatus } = useSession();
|
|
57
|
+
const currentUserId = (session?.user as any)?.id;
|
|
58
|
+
const [userMap, setUserMap] = useState<Record<string, string>>({});
|
|
59
|
+
|
|
60
|
+
// Helper function to check if user is the owner
|
|
61
|
+
const isPostOwner = (post: PostListItem): boolean => {
|
|
62
|
+
if (sessionStatus === 'loading') return false; // Don't show actions while loading
|
|
63
|
+
if (!currentUserId || !post.authorId) return false;
|
|
64
|
+
// Convert both to strings for comparison to handle ObjectId vs string
|
|
65
|
+
return String(currentUserId) === String(post.authorId);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Fetch users to map IDs to names
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const fetchUsers = async () => {
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch('/api/users');
|
|
73
|
+
const users = await response.json();
|
|
74
|
+
if (Array.isArray(users)) {
|
|
75
|
+
const map: Record<string, string> = {};
|
|
76
|
+
users.forEach((user: { _id: string; name?: string; email?: string }) => {
|
|
77
|
+
const id = user._id?.toString();
|
|
78
|
+
if (id) {
|
|
79
|
+
map[id] = user.name || user.email || 'Unknown';
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
setUserMap(map);
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('Failed to fetch users:', error);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
fetchUsers();
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const getAuthorName = (authorId?: string) => {
|
|
92
|
+
if (!authorId) return 'Unknown';
|
|
93
|
+
return userMap[authorId] || authorId;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
98
|
+
{posts.map((post) => (
|
|
99
|
+
<div
|
|
100
|
+
key={post.id}
|
|
101
|
+
className="bg-dashboard-card rounded-2xl border border-dashboard-border overflow-hidden hover:shadow-xl transition-all duration-300 group"
|
|
102
|
+
>
|
|
103
|
+
{/* Featured Image */}
|
|
104
|
+
<div className="relative w-full h-48 bg-neutral-200 dark:bg-neutral-800 overflow-hidden">
|
|
105
|
+
{post.featuredImage ? (
|
|
106
|
+
<Image
|
|
107
|
+
id={post.featuredImage}
|
|
108
|
+
alt={post.title}
|
|
109
|
+
fill
|
|
110
|
+
editable={false}
|
|
111
|
+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
112
|
+
/>
|
|
113
|
+
) : (
|
|
114
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
115
|
+
<span className="text-sm text-neutral-400">No Image</span>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
{/* Actions Menu - Top Left - Only show for own posts */}
|
|
119
|
+
{isPostOwner(post) && (
|
|
120
|
+
<div className="absolute top-4 left-4 z-10">
|
|
121
|
+
<div className="bg-white/90 dark:bg-neutral-900/90 backdrop-blur-sm rounded-full p-1 shadow-lg border border-neutral-200 dark:border-neutral-700">
|
|
122
|
+
<PostActionsMenu
|
|
123
|
+
onEdit={() => onEdit(post.id)}
|
|
124
|
+
onPreview={() => onPreview(post.id)}
|
|
125
|
+
onDuplicate={() => onDuplicate(post.id)}
|
|
126
|
+
onDelete={() => onDelete(post.id)}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
{/* Status Badge Overlay */}
|
|
132
|
+
<div className="absolute top-4 right-4 flex items-center gap-2">
|
|
133
|
+
{/* Owner Badge */}
|
|
134
|
+
{isPostOwner(post) && (
|
|
135
|
+
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border backdrop-blur-sm bg-primary/10 text-primary border-primary/20">
|
|
136
|
+
<UserCheck size={12} />
|
|
137
|
+
Mijn artikel
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
<span
|
|
141
|
+
className={`inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border backdrop-blur-sm ${getStatusBadgeColor(post.status)}`}
|
|
142
|
+
>
|
|
143
|
+
{post.status}
|
|
144
|
+
</span>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{/* Card Content */}
|
|
149
|
+
<div className="p-6">
|
|
150
|
+
{/* Title & Slug */}
|
|
151
|
+
<div className="mb-4">
|
|
152
|
+
<h3 className="font-bold text-lg text-neutral-950 dark:text-white mb-2 line-clamp-2 group-hover:text-primary transition-colors">
|
|
153
|
+
{post.title}
|
|
154
|
+
</h3>
|
|
155
|
+
<p className="text-xs text-neutral-500 dark:text-neutral-400 font-mono">
|
|
156
|
+
/{post.slug}
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Excerpt */}
|
|
161
|
+
{post.excerpt && (
|
|
162
|
+
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4 line-clamp-2">
|
|
163
|
+
{post.excerpt}
|
|
164
|
+
</p>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{/* Meta Information */}
|
|
168
|
+
<div className="space-y-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
|
169
|
+
{/* Author */}
|
|
170
|
+
<div className="flex items-center gap-2">
|
|
171
|
+
<User size={14} className="text-neutral-400" />
|
|
172
|
+
<span className="text-xs text-neutral-600 dark:text-neutral-400">
|
|
173
|
+
{getAuthorName(post.authorId)}
|
|
174
|
+
</span>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Last Modified */}
|
|
178
|
+
<div className="flex items-center gap-2">
|
|
179
|
+
<Calendar size={14} className="text-neutral-400" />
|
|
180
|
+
<span className="text-xs text-neutral-600 dark:text-neutral-400">
|
|
181
|
+
{formatDate(post.updatedAt, locale)}
|
|
182
|
+
</span>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post Filters Component
|
|
3
|
+
* Search and filter controls for posts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { Search, Filter, Tag } from 'lucide-react';
|
|
10
|
+
import { PostStatus } from '../../types/post';
|
|
11
|
+
|
|
12
|
+
export interface PostFiltersProps {
|
|
13
|
+
search: string;
|
|
14
|
+
onSearchChange: (value: string) => void;
|
|
15
|
+
statusFilter: PostStatus | 'all';
|
|
16
|
+
onStatusFilterChange: (value: PostStatus | 'all') => void;
|
|
17
|
+
categoryFilter: string;
|
|
18
|
+
onCategoryFilterChange: (value: string) => void;
|
|
19
|
+
categories: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function PostFilters({
|
|
23
|
+
search,
|
|
24
|
+
onSearchChange,
|
|
25
|
+
statusFilter,
|
|
26
|
+
onStatusFilterChange,
|
|
27
|
+
categoryFilter,
|
|
28
|
+
onCategoryFilterChange,
|
|
29
|
+
categories,
|
|
30
|
+
}: PostFiltersProps) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
|
33
|
+
{/* Search Input */}
|
|
34
|
+
<div className="relative flex-1">
|
|
35
|
+
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-400 size-4" />
|
|
36
|
+
<input
|
|
37
|
+
type="text"
|
|
38
|
+
value={search}
|
|
39
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
40
|
+
placeholder="Search posts by title or content..."
|
|
41
|
+
className="w-full pl-11 pr-4 py-3 bg-neutral-100 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 rounded-2xl text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{/* Status Filter */}
|
|
46
|
+
<div className="relative">
|
|
47
|
+
<Filter className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-400 size-4 pointer-events-none" />
|
|
48
|
+
<select
|
|
49
|
+
value={statusFilter}
|
|
50
|
+
onChange={(e) => onStatusFilterChange(e.target.value as PostStatus | 'all')}
|
|
51
|
+
className="pl-11 pr-8 py-3 bg-neutral-100 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 rounded-2xl text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary appearance-none outline-none cursor-pointer min-w-[160px]"
|
|
52
|
+
>
|
|
53
|
+
<option value="all">All Statuses</option>
|
|
54
|
+
<option value="published">Published</option>
|
|
55
|
+
<option value="draft">Draft</option>
|
|
56
|
+
<option value="scheduled">Scheduled</option>
|
|
57
|
+
<option value="archived">Archived</option>
|
|
58
|
+
</select>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{/* Category Filter */}
|
|
62
|
+
<div className="relative">
|
|
63
|
+
<Tag className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-400 size-4 pointer-events-none" />
|
|
64
|
+
<select
|
|
65
|
+
value={categoryFilter}
|
|
66
|
+
onChange={(e) => onCategoryFilterChange(e.target.value)}
|
|
67
|
+
className="pl-11 pr-8 py-3 bg-neutral-100 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 rounded-2xl text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary appearance-none outline-none cursor-pointer min-w-[160px]"
|
|
68
|
+
>
|
|
69
|
+
<option value="all">All Categories</option>
|
|
70
|
+
{categories.map((category) => (
|
|
71
|
+
<option key={category} value={category}>
|
|
72
|
+
{category}
|
|
73
|
+
</option>
|
|
74
|
+
))}
|
|
75
|
+
</select>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|