@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,280 @@
1
+ /**
2
+ * Post Manager View
3
+ * Production-ready listing page for managing blog posts
4
+ * Follows dashboard earth-tone design system
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React, { useState, useEffect } from 'react';
10
+ import { Plus, List, Grid3x3 } from 'lucide-react';
11
+ import { PostListItem, PostStatus } from '../../types/post';
12
+ import { PostStats } from './PostStats';
13
+ import { PostFilters } from './PostFilters';
14
+ import { PostTable } from './PostTable';
15
+ import { PostCards } from './PostCards';
16
+ import { EmptyState } from './EmptyState';
17
+ import { apiToBlogPost, type APIBlogDocument } from '../../lib/mappers/apiMapper';
18
+
19
+ export interface PostManagerViewProps {
20
+ siteId: string;
21
+ locale: string;
22
+ }
23
+
24
+ type ViewMode = 'list' | 'cards';
25
+
26
+ export function PostManagerView({ siteId, locale }: PostManagerViewProps) {
27
+ const [posts, setPosts] = useState<PostListItem[]>([]);
28
+ const [loading, setLoading] = useState(true);
29
+ const [search, setSearch] = useState('');
30
+ const [statusFilter, setStatusFilter] = useState<PostStatus | 'all'>('all');
31
+ const [categoryFilter, setCategoryFilter] = useState<string>('all');
32
+ const [viewMode, setViewMode] = useState<ViewMode>('list');
33
+
34
+ // Fetch posts from API
35
+ useEffect(() => {
36
+ const fetchPosts = async () => {
37
+ try {
38
+ setLoading(true);
39
+ const response = await fetch('/api/plugin-blog?admin=true');
40
+ const data = await response.json();
41
+
42
+ if (data.blogs && Array.isArray(data.blogs)) {
43
+ // Convert API format to PostListItem format
44
+ const postListItems: PostListItem[] = data.blogs.map((doc: APIBlogDocument) => {
45
+ const blogPost = apiToBlogPost(doc);
46
+ // Extract image ID/filename from src URL
47
+ // src can be: "/api/uploads/filename.jpg" or "filename.jpg" or a full URL
48
+ let featuredImageId: string | undefined = undefined;
49
+ if (blogPost.metadata.featuredImage?.src) {
50
+ const src = blogPost.metadata.featuredImage.src;
51
+ // If it's a URL, extract the filename
52
+ if (src.includes('/')) {
53
+ const parts = src.split('/');
54
+ featuredImageId = parts[parts.length - 1];
55
+ } else {
56
+ // Already a filename/ID
57
+ featuredImageId = src;
58
+ }
59
+ }
60
+ // Extract category from metadata or hero block
61
+ let category: string | undefined = undefined;
62
+ if (blogPost.metadata.categories && blogPost.metadata.categories.length > 0) {
63
+ category = blogPost.metadata.categories[0];
64
+ } else {
65
+ // Check hero block for category
66
+ const heroBlock = blogPost.blocks.find(block => block.type === 'hero');
67
+ if (heroBlock && heroBlock.data && typeof heroBlock.data === 'object') {
68
+ const heroCategory = (heroBlock.data as any).category;
69
+ if (heroCategory && typeof heroCategory === 'string' && heroCategory.trim()) {
70
+ category = heroCategory.trim();
71
+ }
72
+ }
73
+ }
74
+ return {
75
+ id: blogPost.id,
76
+ title: blogPost.title,
77
+ slug: blogPost.slug,
78
+ status: blogPost.publication.status,
79
+ date: blogPost.publication.date,
80
+ excerpt: blogPost.metadata.excerpt,
81
+ featuredImage: featuredImageId,
82
+ authorId: blogPost.publication.authorId,
83
+ updatedAt: blogPost.updatedAt,
84
+ category: category,
85
+ };
86
+ });
87
+ setPosts(postListItems);
88
+ }
89
+ } catch (error) {
90
+ console.error('Failed to fetch posts:', error);
91
+ } finally {
92
+ setLoading(false);
93
+ }
94
+ };
95
+
96
+ fetchPosts();
97
+ }, []);
98
+
99
+ // Extract unique categories from posts
100
+ const categories = React.useMemo(() => {
101
+ const categorySet = new Set<string>();
102
+ posts.forEach(post => {
103
+ if (post.category && post.category.trim()) {
104
+ categorySet.add(post.category.trim());
105
+ }
106
+ });
107
+ return Array.from(categorySet).sort();
108
+ }, [posts]);
109
+
110
+ // Filter posts
111
+ const filteredPosts = React.useMemo(() => {
112
+ return posts.filter((post) => {
113
+ const matchesSearch =
114
+ search === '' ||
115
+ post.title.toLowerCase().includes(search.toLowerCase()) ||
116
+ post.excerpt?.toLowerCase().includes(search.toLowerCase()) ||
117
+ post.slug.toLowerCase().includes(search.toLowerCase());
118
+
119
+ const matchesStatus = statusFilter === 'all' || post.status === statusFilter;
120
+
121
+ const matchesCategory = categoryFilter === 'all' || post.category === categoryFilter;
122
+
123
+ return matchesSearch && matchesStatus && matchesCategory;
124
+ });
125
+ }, [posts, search, statusFilter, categoryFilter]);
126
+
127
+ // Action handlers
128
+ const handleCreatePost = () => {
129
+ // Navigate to editor route - the plugin router will handle this
130
+ // The route 'new' maps to the editor view
131
+ window.location.href = '/dashboard/blog/new';
132
+ };
133
+
134
+ const handleEdit = (postId: string) => {
135
+ // Find the post to get its slug
136
+ const post = posts.find(p => p.id === postId);
137
+ if (post) {
138
+ // Navigate to editor with slug (API uses slug, not ID)
139
+ window.location.href = `/dashboard/blog/editor/${post.slug}`;
140
+ }
141
+ };
142
+
143
+ const handlePreview = (postId: string) => {
144
+ // Open preview in new tab
145
+ window.open(`/dashboard/blog/preview/${postId}`, '_blank');
146
+ };
147
+
148
+ const handleDuplicate = (postId: string) => {
149
+ // TODO: Implement duplicate functionality
150
+ const post = posts.find((p) => p.id === postId);
151
+ if (post) {
152
+ const duplicated: PostListItem = {
153
+ ...post,
154
+ id: `duplicate-${Date.now()}`,
155
+ title: `${post.title} (Copy)`,
156
+ slug: `${post.slug}-copy-${Date.now()}`,
157
+ status: 'draft',
158
+ updatedAt: new Date().toISOString(),
159
+ };
160
+ setPosts((prev) => [...prev, duplicated]);
161
+ }
162
+ };
163
+
164
+ const handleDelete = async (postId: string) => {
165
+ if (confirm('Are you sure you want to delete this post?')) {
166
+ try {
167
+ const post = posts.find(p => p.id === postId);
168
+ if (post) {
169
+ const response = await fetch(`/api/plugin-blog/${post.slug}`, {
170
+ method: 'DELETE',
171
+ });
172
+
173
+ if (response.ok) {
174
+ // Remove from local state
175
+ setPosts((prev) => prev.filter((p) => p.id !== postId));
176
+ } else {
177
+ const error = await response.json();
178
+ alert(error.error || 'Failed to delete post');
179
+ }
180
+ }
181
+ } catch (error) {
182
+ console.error('Failed to delete post:', error);
183
+ alert('Failed to delete post');
184
+ }
185
+ }
186
+ };
187
+
188
+ const hasActiveFilters = search !== '' || statusFilter !== 'all' || categoryFilter !== 'all';
189
+
190
+ return (
191
+ <div className="h-full w-full rounded-[2.5rem] bg-white dark:bg-neutral-900 p-8 overflow-y-auto">
192
+ {/* Header Section */}
193
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
194
+ <div>
195
+ <h1 className="text-3xl font-black text-neutral-950 dark:text-white uppercase tracking-tighter mb-2">
196
+ Blog Posts
197
+ </h1>
198
+ <p className="text-sm text-neutral-500 dark:text-neutral-400">
199
+ Manage your blog posts, drafts, and published content
200
+ </p>
201
+ </div>
202
+
203
+ <button
204
+ onClick={handleCreatePost}
205
+ 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"
206
+ >
207
+ <Plus size={16} />
208
+ New Post
209
+ </button>
210
+ </div>
211
+
212
+ {/* Stats Summary */}
213
+ <PostStats posts={posts} />
214
+
215
+ {/* Filters & Search Bar with View Toggle */}
216
+ <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
217
+ <PostFilters
218
+ search={search}
219
+ onSearchChange={setSearch}
220
+ statusFilter={statusFilter}
221
+ onStatusFilterChange={setStatusFilter}
222
+ categoryFilter={categoryFilter}
223
+ onCategoryFilterChange={setCategoryFilter}
224
+ categories={categories}
225
+ />
226
+
227
+ {/* View Toggle */}
228
+ <div className="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800/50 rounded-full p-1 border border-neutral-300 dark:border-neutral-700">
229
+ <button
230
+ onClick={() => setViewMode('list')}
231
+ className={`p-2 rounded-full transition-all ${viewMode === 'list'
232
+ ? 'bg-white dark:bg-neutral-900 text-primary shadow-sm'
233
+ : 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
234
+ }`}
235
+ title="List View"
236
+ >
237
+ <List size={18} />
238
+ </button>
239
+ <button
240
+ onClick={() => setViewMode('cards')}
241
+ className={`p-2 rounded-full transition-all ${viewMode === 'cards'
242
+ ? 'bg-white dark:bg-neutral-900 text-primary shadow-sm'
243
+ : 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
244
+ }`}
245
+ title="Card View"
246
+ >
247
+ <Grid3x3 size={18} />
248
+ </button>
249
+ </div>
250
+ </div>
251
+
252
+ {/* Content */}
253
+ {loading ? (
254
+ <div className="flex items-center justify-center py-20">
255
+ <div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
256
+ </div>
257
+ ) : filteredPosts.length === 0 ? (
258
+ <EmptyState hasFilters={hasActiveFilters} onCreatePost={handleCreatePost} />
259
+ ) : viewMode === 'list' ? (
260
+ <PostTable
261
+ posts={filteredPosts}
262
+ locale={locale}
263
+ onEdit={handleEdit}
264
+ onPreview={handlePreview}
265
+ onDuplicate={handleDuplicate}
266
+ onDelete={handleDelete}
267
+ />
268
+ ) : (
269
+ <PostCards
270
+ posts={filteredPosts}
271
+ locale={locale}
272
+ onEdit={handleEdit}
273
+ onPreview={handlePreview}
274
+ onDuplicate={handleDuplicate}
275
+ onDelete={handleDelete}
276
+ />
277
+ )}
278
+ </div>
279
+ );
280
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Post Stats Component
3
+ * Displays summary statistics for blog posts
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+ import { FileText, CheckCircle2, Clock, Archive } from 'lucide-react';
10
+ import { PostListItem, PostStatus } from '../../types/post';
11
+
12
+ export interface PostStatsProps {
13
+ posts: PostListItem[];
14
+ }
15
+
16
+ export function PostStats({ posts }: PostStatsProps) {
17
+ const total = posts.length;
18
+ const published = posts.filter(p => p.status === 'published').length;
19
+ const drafts = posts.filter(p => p.status === 'draft').length;
20
+ const scheduled = posts.filter(p => p.status === 'scheduled').length;
21
+
22
+ const stats = [
23
+ {
24
+ label: 'Total Posts',
25
+ value: total,
26
+ icon: FileText,
27
+ color: 'text-neutral-600 dark:text-neutral-400',
28
+ bgColor: 'bg-neutral-100 dark:bg-neutral-800',
29
+ },
30
+ {
31
+ label: 'Published',
32
+ value: published,
33
+ icon: CheckCircle2,
34
+ color: 'text-green-600 dark:text-green-400',
35
+ bgColor: 'bg-green-500/10 dark:bg-green-500/20',
36
+ },
37
+ {
38
+ label: 'Drafts',
39
+ value: drafts,
40
+ icon: Clock,
41
+ color: 'text-amber-600 dark:text-amber-400',
42
+ bgColor: 'bg-amber-500/10 dark:bg-amber-500/20',
43
+ },
44
+ {
45
+ label: 'Scheduled',
46
+ value: scheduled,
47
+ icon: Archive,
48
+ color: 'text-blue-600 dark:text-blue-400',
49
+ bgColor: 'bg-blue-500/10 dark:bg-blue-500/20',
50
+ },
51
+ ];
52
+
53
+ return (
54
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
55
+ {stats.map((stat) => {
56
+ const Icon = stat.icon;
57
+ return (
58
+ <div
59
+ key={stat.label}
60
+ className={`p-4 rounded-2xl border border-neutral-300 dark:border-neutral-700 ${stat.bgColor}`}
61
+ >
62
+ <div className="flex items-center gap-3">
63
+ <div className={`p-2 rounded-xl ${stat.bgColor}`}>
64
+ <Icon className={`size-5 ${stat.color}`} />
65
+ </div>
66
+ <div>
67
+ <p className="text-2xl font-black text-neutral-950 dark:text-white">
68
+ {stat.value}
69
+ </p>
70
+ <p className="text-xs text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
71
+ {stat.label}
72
+ </p>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ );
77
+ })}
78
+ </div>
79
+ );
80
+ }
81
+
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Post Table Component
3
+ * Professional table 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 PostTableProps {
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 PostTable({
49
+ posts,
50
+ locale,
51
+ onEdit,
52
+ onPreview,
53
+ onDuplicate,
54
+ onDelete,
55
+ }: PostTableProps) {
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="bg-neutral-100 dark:bg-neutral-800/50 rounded-[2.5rem] border border-neutral-300 dark:border-neutral-700 overflow-hidden">
98
+ <div className="overflow-x-auto">
99
+ <table className="w-full">
100
+ <thead className="bg-neutral-200 dark:bg-neutral-900/50 border-b border-neutral-300 dark:border-neutral-700">
101
+ <tr>
102
+ <th className="px-6 py-4 text-left text-[10px] font-black uppercase tracking-widest text-neutral-600 dark:text-neutral-400">
103
+ Post
104
+ </th>
105
+ <th className="px-6 py-4 text-left text-[10px] font-black uppercase tracking-widest text-neutral-600 dark:text-neutral-400">
106
+ Author
107
+ </th>
108
+ <th className="px-6 py-4 text-left text-[10px] font-black uppercase tracking-widest text-neutral-600 dark:text-neutral-400">
109
+ Category
110
+ </th>
111
+ <th className="px-6 py-4 text-left text-[10px] font-black uppercase tracking-widest text-neutral-600 dark:text-neutral-400">
112
+ Status
113
+ </th>
114
+ <th className="px-6 py-4 text-left text-[10px] font-black uppercase tracking-widest text-neutral-600 dark:text-neutral-400">
115
+ Last Modified
116
+ </th>
117
+ <th className="px-6 py-4 text-right text-[10px] font-black uppercase tracking-widest text-neutral-600 dark:text-neutral-400">
118
+ Actions
119
+ </th>
120
+ </tr>
121
+ </thead>
122
+ <tbody className="divide-y divide-neutral-300 dark:divide-neutral-700">
123
+ {posts.map((post) => (
124
+ <tr
125
+ key={post.id}
126
+ className="hover:bg-white dark:hover:bg-neutral-900/50 transition-colors cursor-pointer"
127
+ >
128
+ {/* Featured Image & Title */}
129
+ <td className="px-6 py-4">
130
+ <div className="flex items-center gap-4">
131
+ {post.featuredImage ? (
132
+ <div className="w-16 h-16 rounded-xl bg-neutral-200 dark:bg-neutral-700 overflow-hidden flex-shrink-0 relative">
133
+ <Image
134
+ id={post.featuredImage}
135
+ alt={post.title}
136
+ fill
137
+ editable={false}
138
+ className="w-full h-full object-cover"
139
+ />
140
+ </div>
141
+ ) : (
142
+ <div className="w-16 h-16 rounded-xl bg-neutral-200 dark:bg-neutral-700 flex items-center justify-center flex-shrink-0">
143
+ <span className="text-xs text-neutral-400">No Image</span>
144
+ </div>
145
+ )}
146
+ <div className="min-w-0 flex-1">
147
+ <h3 className="font-bold text-neutral-950 dark:text-white mb-1 line-clamp-1">
148
+ {post.title}
149
+ </h3>
150
+ <p className="text-xs text-neutral-500 dark:text-neutral-400 font-mono">
151
+ /{post.slug}
152
+ </p>
153
+ </div>
154
+ </div>
155
+ </td>
156
+
157
+ {/* Author */}
158
+ <td className="px-6 py-4">
159
+ <div className="flex items-center gap-2">
160
+ {isPostOwner(post) ? (
161
+ <UserCheck size={14} className="text-primary" />
162
+ ) : (
163
+ <User size={14} className="text-neutral-400" />
164
+ )}
165
+ <span className={`text-sm ${isPostOwner(post) ? 'text-primary font-semibold' : 'text-neutral-600 dark:text-neutral-400'}`}>
166
+ {getAuthorName(post.authorId)}
167
+ {isPostOwner(post) && (
168
+ <span className="ml-2 text-xs text-primary/70">(Jij)</span>
169
+ )}
170
+ </span>
171
+ </div>
172
+ </td>
173
+
174
+ {/* Category */}
175
+ <td className="px-6 py-4">
176
+ <span className="text-sm text-neutral-600 dark:text-neutral-400">
177
+ {post.category || 'Uncategorized'}
178
+ </span>
179
+ </td>
180
+
181
+ {/* Status Badge */}
182
+ <td className="px-6 py-4">
183
+ <span
184
+ className={`inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border ${getStatusBadgeColor(post.status)}`}
185
+ >
186
+ {post.status}
187
+ </span>
188
+ </td>
189
+
190
+ {/* Last Modified */}
191
+ <td className="px-6 py-4">
192
+ <div className="flex items-center gap-2">
193
+ <Calendar size={14} className="text-neutral-400" />
194
+ <span className="text-sm text-neutral-600 dark:text-neutral-400">
195
+ {formatDate(post.updatedAt, locale)}
196
+ </span>
197
+ </div>
198
+ </td>
199
+
200
+ {/* Actions Menu - Only show for own posts */}
201
+ <td className="px-6 py-4">
202
+ {isPostOwner(post) ? (
203
+ <div className="flex items-center justify-end">
204
+ <PostActionsMenu
205
+ onEdit={() => onEdit(post.id)}
206
+ onPreview={() => onPreview(post.id)}
207
+ onDuplicate={() => onDuplicate(post.id)}
208
+ onDelete={() => onDelete(post.id)}
209
+ />
210
+ </div>
211
+ ) : (
212
+ <div className="flex items-center justify-end text-neutral-400 text-xs">
213
+ Alleen auteur
214
+ </div>
215
+ )}
216
+ </td>
217
+ </tr>
218
+ ))}
219
+ </tbody>
220
+ </table>
221
+ </div>
222
+ </div>
223
+ );
224
+ }
225
+
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Post Manager View Exports
3
+ */
4
+
5
+ export { PostManagerView } from './PostManagerView';
6
+ export type { PostManagerViewProps } from './PostManagerView';
7
+
8
+ // Sub-components (for potential reuse)
9
+ export { PostStats } from './PostStats';
10
+ export { PostFilters } from './PostFilters';
11
+ export { PostTable } from './PostTable';
12
+ export { PostCards } from './PostCards';
13
+ export { EmptyState } from './EmptyState';
14
+ export { PostActionsMenu } from './PostActionsMenu';
15
+
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Preview Bridge View
3
+ * Live preview of blog post
4
+ * Follows dashboard earth-tone design system
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React from 'react';
10
+ import { ExternalLink, RefreshCw } from 'lucide-react';
11
+
12
+ export interface PreviewBridgeViewProps {
13
+ postId?: string;
14
+ siteId: string;
15
+ locale: string;
16
+ }
17
+
18
+ export function PreviewBridgeView({ postId, siteId, locale }: PreviewBridgeViewProps) {
19
+ return (
20
+ <div className="h-full w-full bg-white dark:bg-neutral-900 flex flex-col">
21
+ {/* Header */}
22
+ <div className="flex items-center justify-between p-6 border-b border-neutral-300 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800/50">
23
+ <div>
24
+ <h1 className="text-xl font-black text-neutral-950 dark:text-white uppercase tracking-tighter">
25
+ Preview
26
+ </h1>
27
+ <p className="text-xs text-neutral-500 dark:text-neutral-400">
28
+ {postId ? `Previewing post: ${postId}` : 'Live preview of your post'}
29
+ </p>
30
+ </div>
31
+
32
+ <div className="flex items-center gap-3">
33
+ <button className="inline-flex items-center gap-2 px-4 py-2 bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-full text-[10px] font-black uppercase tracking-widest hover:bg-neutral-300 dark:hover:bg-neutral-600 transition-all">
34
+ <RefreshCw size={14} />
35
+ Refresh
36
+ </button>
37
+ <button className="inline-flex items-center gap-2 px-4 py-2 bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-full text-[10px] font-black uppercase tracking-widest hover:bg-neutral-300 dark:hover:bg-neutral-600 transition-all">
38
+ <ExternalLink size={14} />
39
+ Open in New Tab
40
+ </button>
41
+ </div>
42
+ </div>
43
+
44
+ {/* Preview Container */}
45
+ <div className="flex-1 overflow-hidden bg-neutral-100 dark:bg-neutral-800/30">
46
+ <div className="h-full w-full p-8">
47
+ <div className="h-full w-full bg-white dark:bg-neutral-900 rounded-[2.5rem] border border-neutral-300 dark:border-neutral-700 overflow-hidden">
48
+ <div className="h-full w-full flex items-center justify-center">
49
+ <div className="text-center">
50
+ <p className="text-sm text-neutral-500 dark:text-neutral-400 mb-2">
51
+ Preview will be rendered here
52
+ </p>
53
+ <p className="text-xs text-neutral-400">
54
+ Using iframe or side-by-side panel
55
+ </p>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ );
63
+ }
64
+
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Preview Bridge View Exports
3
+ */
4
+
5
+ export { PreviewBridgeView } from './PreviewBridgeView';
6
+ export type { PreviewBridgeViewProps } from './PreviewBridgeView';
7
+