@actuate-media/cms-admin 0.2.1 → 0.4.0

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 (38) hide show
  1. package/dist/AdminRoot.js +2 -2
  2. package/dist/AdminRoot.js.map +1 -1
  3. package/dist/actuate-admin.css +1 -1
  4. package/dist/components/ContentOverviewChart.d.ts +8 -0
  5. package/dist/components/ContentOverviewChart.d.ts.map +1 -0
  6. package/dist/components/ContentOverviewChart.js +20 -0
  7. package/dist/components/ContentOverviewChart.js.map +1 -0
  8. package/dist/components/SEOPerformance.d.ts +5 -0
  9. package/dist/components/SEOPerformance.d.ts.map +1 -0
  10. package/dist/components/SEOPerformance.js +24 -0
  11. package/dist/components/SEOPerformance.js.map +1 -0
  12. package/dist/layout/Sidebar.d.ts +7 -0
  13. package/dist/layout/Sidebar.d.ts.map +1 -1
  14. package/dist/layout/Sidebar.js +34 -10
  15. package/dist/layout/Sidebar.js.map +1 -1
  16. package/dist/views/Dashboard.d.ts +2 -1
  17. package/dist/views/Dashboard.d.ts.map +1 -1
  18. package/dist/views/Dashboard.js +81 -32
  19. package/dist/views/Dashboard.js.map +1 -1
  20. package/dist/views/DocumentEdit.d.ts.map +1 -1
  21. package/dist/views/DocumentEdit.js +49 -5
  22. package/dist/views/DocumentEdit.js.map +1 -1
  23. package/dist/views/Pages.d.ts.map +1 -1
  24. package/dist/views/Pages.js +57 -2
  25. package/dist/views/Pages.js.map +1 -1
  26. package/dist/views/Settings.d.ts +2 -1
  27. package/dist/views/Settings.d.ts.map +1 -1
  28. package/dist/views/Settings.js +31 -3
  29. package/dist/views/Settings.js.map +1 -1
  30. package/package.json +3 -2
  31. package/src/AdminRoot.tsx +2 -2
  32. package/src/components/ContentOverviewChart.tsx +70 -0
  33. package/src/components/SEOPerformance.tsx +134 -0
  34. package/src/layout/Sidebar.tsx +70 -22
  35. package/src/views/Dashboard.tsx +175 -192
  36. package/src/views/DocumentEdit.tsx +82 -4
  37. package/src/views/Pages.tsx +132 -58
  38. package/src/views/Settings.tsx +79 -2
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+
3
+ import { Search, AlertTriangle, FileWarning, ImageOff, LinkIcon, ChevronLeft, ChevronRight } from 'lucide-react';
4
+ import { useState } from 'react';
5
+ import { useApiData } from '../lib/useApiData.js';
6
+
7
+ interface SEOSummary {
8
+ totalPages: number;
9
+ issuesSummary: {
10
+ missingMetaDescriptions: number;
11
+ brokenInternalLinks: number;
12
+ missingAltText: number;
13
+ };
14
+ topContent: { id: string; title: string; collection: string; score: number }[];
15
+ }
16
+
17
+ function ScoreBadge({ score }: { score: number }) {
18
+ const color = score >= 80 ? 'text-green-600' : score >= 60 ? 'text-amber-500' : 'text-red-500';
19
+ const bg = score >= 80 ? 'bg-green-50' : score >= 60 ? 'bg-amber-50' : 'bg-red-50';
20
+ return (
21
+ <span className={`inline-flex items-center justify-center w-9 h-9 rounded-full text-sm font-semibold ${color} ${bg}`}>
22
+ {score}
23
+ </span>
24
+ );
25
+ }
26
+
27
+ export interface SEOPerformanceProps {
28
+ onNavigate?: (path: string) => void;
29
+ }
30
+
31
+ export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
32
+ const { data, loading, error } = useApiData<SEOSummary>('/seo/summary');
33
+ const [page, setPage] = useState(0);
34
+ const perPage = 5;
35
+
36
+ if (loading || error || !data) return null;
37
+
38
+ const issues = data.issuesSummary;
39
+ const totalIssues = issues.missingMetaDescriptions + issues.brokenInternalLinks + issues.missingAltText;
40
+ const topContent = data.topContent ?? [];
41
+ const totalPages = Math.max(1, Math.ceil(topContent.length / perPage));
42
+ const visible = topContent.slice(page * perPage, (page + 1) * perPage);
43
+
44
+ return (
45
+ <div className="bg-white rounded-lg border border-gray-200">
46
+ <div className="p-4 border-b border-gray-200 flex items-center justify-between">
47
+ <div className="flex items-center gap-2">
48
+ <Search className="w-4 h-4 text-gray-500" />
49
+ <h2 className="text-sm font-semibold text-gray-900">SEO Performance</h2>
50
+ </div>
51
+ {totalIssues > 0 && (
52
+ <span className="text-xs text-gray-500">{totalIssues} issue{totalIssues !== 1 ? 's' : ''} found</span>
53
+ )}
54
+ </div>
55
+
56
+ <div className="grid grid-cols-1 lg:grid-cols-12 divide-y lg:divide-y-0 lg:divide-x divide-gray-200">
57
+ <div className="lg:col-span-7 p-4">
58
+ <h3 className="text-sm font-medium text-gray-700 mb-3">Top Performing Content</h3>
59
+ <div className="space-y-2">
60
+ {visible.map((item) => (
61
+ <div key={item.id} className="flex items-center justify-between py-2">
62
+ <div className="flex-1 min-w-0 mr-3">
63
+ <p className="text-sm font-medium text-gray-900 truncate">{item.title}</p>
64
+ <p className="text-xs text-gray-500 capitalize">{item.collection}</p>
65
+ </div>
66
+ <ScoreBadge score={item.score} />
67
+ </div>
68
+ ))}
69
+ {visible.length === 0 && (
70
+ <p className="text-sm text-gray-400 py-4 text-center">No published content yet</p>
71
+ )}
72
+ </div>
73
+ {topContent.length > perPage && (
74
+ <div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100 text-xs text-gray-500">
75
+ <span>Showing {page * perPage + 1}-{Math.min((page + 1) * perPage, topContent.length)} of {topContent.length}</span>
76
+ <div className="flex items-center gap-1">
77
+ <button
78
+ onClick={() => setPage(Math.max(0, page - 1))}
79
+ disabled={page === 0}
80
+ className="p-1 rounded hover:bg-gray-100 disabled:opacity-30"
81
+ >
82
+ <ChevronLeft className="w-3.5 h-3.5" />
83
+ </button>
84
+ <span>Page {page + 1} of {totalPages}</span>
85
+ <button
86
+ onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
87
+ disabled={page >= totalPages - 1}
88
+ className="p-1 rounded hover:bg-gray-100 disabled:opacity-30"
89
+ >
90
+ <ChevronRight className="w-3.5 h-3.5" />
91
+ </button>
92
+ </div>
93
+ </div>
94
+ )}
95
+ </div>
96
+
97
+ <div className="lg:col-span-5 p-4">
98
+ <h3 className="text-sm font-medium text-gray-700 mb-3">SEO Issues</h3>
99
+ <div className="space-y-3">
100
+ <div className="flex items-center justify-between">
101
+ <div className="flex items-center gap-2">
102
+ <FileWarning className="w-4 h-4 text-red-400" />
103
+ <span className="text-sm text-gray-700">Missing meta descriptions</span>
104
+ </div>
105
+ <span className="text-sm font-semibold text-gray-900">{issues.missingMetaDescriptions}</span>
106
+ </div>
107
+ <div className="flex items-center justify-between">
108
+ <div className="flex items-center gap-2">
109
+ <LinkIcon className="w-4 h-4 text-red-400" />
110
+ <span className="text-sm text-gray-700">Broken internal links</span>
111
+ </div>
112
+ <span className="text-sm font-semibold text-gray-900">{issues.brokenInternalLinks}</span>
113
+ </div>
114
+ <div className="flex items-center justify-between">
115
+ <div className="flex items-center gap-2">
116
+ <ImageOff className="w-4 h-4 text-red-400" />
117
+ <span className="text-sm text-gray-700">Missing alt text</span>
118
+ </div>
119
+ <span className="text-sm font-semibold text-gray-900">{issues.missingAltText}</span>
120
+ </div>
121
+ </div>
122
+ {totalIssues > 0 && (
123
+ <button
124
+ onClick={() => onNavigate?.('/seo')}
125
+ className="mt-4 text-sm text-blue-600 hover:text-blue-700 font-medium"
126
+ >
127
+ View All Issues
128
+ </button>
129
+ )}
130
+ </div>
131
+ </div>
132
+ </div>
133
+ );
134
+ }
@@ -16,6 +16,9 @@ import {
16
16
  BookOpen,
17
17
  HelpCircle,
18
18
  Newspaper,
19
+ PanelTop,
20
+ PanelBottom,
21
+ Layers,
19
22
  } from 'lucide-react';
20
23
  import type { LucideIcon } from 'lucide-react';
21
24
 
@@ -53,6 +56,9 @@ const ICON_MAP: Record<string, LucideIcon> = {
53
56
  book: BookOpen,
54
57
  help: HelpCircle,
55
58
  newspaper: Newspaper,
59
+ PanelTop: PanelTop,
60
+ PanelBottom: PanelBottom,
61
+ Layers: Layers,
56
62
  };
57
63
 
58
64
  function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean }) {
@@ -143,28 +149,44 @@ export function Sidebar({ collapsed, onToggleCollapse, currentPath, onNavigate,
143
149
  </div>
144
150
 
145
151
  <nav className="p-3 space-y-1">
146
- {navItems.map((item) => {
152
+ {navItems.map((item, idx) => {
147
153
  const Icon = item.icon;
148
154
  const isActive =
149
155
  currentPath === item.path ||
150
156
  (item.path !== '/' && currentPath.startsWith(item.path));
151
157
 
158
+ const prevGroup = idx > 0 ? navItems[idx - 1]?.group : undefined;
159
+ const showGroupLabel = item.group && item.group !== prevGroup;
160
+
152
161
  return (
153
- <button
154
- key={item.path}
155
- onClick={() => onNavigate(item.path)}
156
- className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors w-full text-left ${
157
- isActive
158
- ? 'bg-sidebar-accent text-sidebar-primary'
159
- : 'text-sidebar-foreground hover:bg-sidebar-accent'
160
- } ${collapsed ? 'justify-center' : ''}`}
161
- title={collapsed ? item.label : ''}
162
- >
163
- <Icon className="w-5 h-5 shrink-0" />
164
- {!collapsed && (
165
- <span className="text-sm font-medium">{item.label}</span>
162
+ <div key={item.path}>
163
+ {showGroupLabel && !collapsed && (
164
+ <div className="pt-3 pb-1 px-3">
165
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/50">
166
+ {item.group}
167
+ </span>
168
+ </div>
169
+ )}
170
+ {showGroupLabel && collapsed && (
171
+ <div className="pt-2 pb-1 flex justify-center">
172
+ <span className="w-4 border-t border-sidebar-foreground/20" />
173
+ </div>
166
174
  )}
167
- </button>
175
+ <button
176
+ onClick={() => onNavigate(item.path)}
177
+ className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors w-full text-left ${
178
+ isActive
179
+ ? 'bg-sidebar-accent text-sidebar-primary'
180
+ : 'text-sidebar-foreground hover:bg-sidebar-accent'
181
+ } ${collapsed ? 'justify-center' : ''}`}
182
+ title={collapsed ? item.label : ''}
183
+ >
184
+ <Icon className="w-5 h-5 shrink-0" />
185
+ {!collapsed && (
186
+ <span className="text-sm font-medium">{item.label}</span>
187
+ )}
188
+ </button>
189
+ </div>
168
190
  );
169
191
  })}
170
192
  </nav>
@@ -178,7 +200,14 @@ function resolveIcon(collection: any): LucideIcon {
178
200
  return collection.type === 'page' ? File : FileText;
179
201
  }
180
202
 
181
- function buildNavItems(config: any) {
203
+ export interface NavItem {
204
+ path: string;
205
+ label: string;
206
+ icon: LucideIcon;
207
+ group?: string;
208
+ }
209
+
210
+ function buildNavItems(config: any): NavItem[] {
182
211
  if (!config?.collections) return defaultNavItems;
183
212
 
184
213
  const raw = config.collections;
@@ -186,17 +215,19 @@ function buildNavItems(config: any) {
186
215
 
187
216
  const visible = collectionsList.filter((c) => !c.admin?.hidden);
188
217
 
189
- const pages = visible.filter((c) => c.type === 'page');
190
- const posts = visible.filter((c) => c.type === 'post');
191
- const other = visible.filter((c) => c.type !== 'page' && c.type !== 'post');
218
+ const ungrouped = visible.filter((c) => !c.admin?.group);
219
+ const grouped = visible.filter((c) => c.admin?.group);
192
220
 
193
- const sorted = [...pages, ...posts, ...other];
221
+ const pages = ungrouped.filter((c) => c.type === 'page');
222
+ const posts = ungrouped.filter((c) => c.type === 'post');
223
+ const other = ungrouped.filter((c) => c.type !== 'page' && c.type !== 'post');
224
+ const sortedUngrouped = [...pages, ...posts, ...other];
194
225
 
195
- const items: typeof defaultNavItems = [
226
+ const items: NavItem[] = [
196
227
  { path: '/', label: 'Dashboard', icon: LayoutDashboard },
197
228
  ];
198
229
 
199
- for (const collection of sorted) {
230
+ for (const collection of sortedUngrouped) {
200
231
  items.push({
201
232
  label: collection.labels?.plural ?? collection.slug,
202
233
  path: `/${collection.slug}`,
@@ -204,6 +235,23 @@ function buildNavItems(config: any) {
204
235
  });
205
236
  }
206
237
 
238
+ const groups = new Map<string, typeof grouped>();
239
+ for (const col of grouped) {
240
+ const group = col.admin.group as string;
241
+ if (!groups.has(group)) groups.set(group, []);
242
+ groups.get(group)!.push(col);
243
+ }
244
+ for (const [groupName, cols] of groups) {
245
+ for (const collection of cols) {
246
+ items.push({
247
+ label: collection.labels?.plural ?? collection.slug,
248
+ path: `/${collection.slug}`,
249
+ icon: resolveIcon(collection),
250
+ group: groupName,
251
+ });
252
+ }
253
+ }
254
+
207
255
  items.push(
208
256
  { path: '/media', label: 'Media', icon: Image },
209
257
  { path: '/forms', label: 'Forms', icon: ClipboardList },