@actuate-media/cms-admin 0.7.0 → 0.7.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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +8 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/views/FormSubmissions.js +11 -11
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.js +1 -1
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +28 -8
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts.js +1 -1
- package/dist/views/Posts.js.map +1 -1
- package/dist/views/Redirects.js +2 -2
- package/dist/views/Redirects.js.map +1 -1
- package/dist/views/SEO.js +3 -3
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/Users.js +3 -3
- package/dist/views/Users.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts +5 -0
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -0
- package/dist/views/page-builder/PageTemplates.js +13 -0
- package/dist/views/page-builder/PageTemplates.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +10 -5
- package/src/views/FormSubmissions.tsx +12 -12
- package/src/views/Forms.tsx +1 -1
- package/src/views/MediaBrowser.tsx +46 -15
- package/src/views/Posts.tsx +1 -1
- package/src/views/Redirects.tsx +2 -2
- package/src/views/SEO.tsx +3 -3
- package/src/views/Users.tsx +3 -3
- package/src/views/page-builder/PageTemplates.tsx +105 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"tailwindcss": "^4.0.0",
|
|
71
71
|
"typescript": "^5.7.0",
|
|
72
72
|
"vitest": "^3.0.0",
|
|
73
|
-
"@actuate-media/cms-core": "0.
|
|
73
|
+
"@actuate-media/cms-core": "0.10.2"
|
|
74
74
|
},
|
|
75
75
|
"scripts": {
|
|
76
76
|
"build": "tsc --project tsconfig.json && npx @tailwindcss/cli -i src/styles/build-input.css -o dist/actuate-admin.css --minify",
|
package/src/AdminRoot.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import { LocaleProvider } from './components/LocaleProvider.js';
|
|
|
25
25
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts.js';
|
|
26
26
|
import { PageBuilder } from './views/page-builder/PageBuilder.js';
|
|
27
27
|
import { SavedSections } from './views/page-builder/SavedSections.js';
|
|
28
|
+
import { PageTemplates } from './views/page-builder/PageTemplates.js';
|
|
28
29
|
|
|
29
30
|
export interface AdminRootProps {
|
|
30
31
|
config: any;
|
|
@@ -101,16 +102,16 @@ function AdminShell({ config, session, basePath = '/admin', initialPath = '/', s
|
|
|
101
102
|
return <Dashboard config={config} session={session} onNavigate={navigate} />;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
const pageBuilderEdit = matchRoute('/page-builder/:id');
|
|
105
|
-
if (pageBuilderEdit?.id) {
|
|
106
|
-
return <PageBuilder documentId={pageBuilderEdit.id} collectionSlug="pages" config={config} onNavigate={navigate} />;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
105
|
const pageBuilderNew = matchRoute('/page-builder/new');
|
|
110
106
|
if (pageBuilderNew) {
|
|
111
107
|
return <PageBuilder collectionSlug="pages" config={config} onNavigate={navigate} />;
|
|
112
108
|
}
|
|
113
109
|
|
|
110
|
+
const pageBuilderEdit = matchRoute('/page-builder/:id');
|
|
111
|
+
if (pageBuilderEdit?.id) {
|
|
112
|
+
return <PageBuilder documentId={pageBuilderEdit.id} collectionSlug="pages" config={config} onNavigate={navigate} />;
|
|
113
|
+
}
|
|
114
|
+
|
|
114
115
|
for (const slug of collectionSlugs) {
|
|
115
116
|
const newMatch = matchRoute(`/${slug}/new`);
|
|
116
117
|
if (newMatch) {
|
|
@@ -187,6 +188,10 @@ function AdminShell({ config, session, basePath = '/admin', initialPath = '/', s
|
|
|
187
188
|
return <SavedSections onNavigate={navigate} config={config} />;
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
if (matchRoute('/page-templates')) {
|
|
192
|
+
return <PageTemplates onNavigate={navigate} />;
|
|
193
|
+
}
|
|
194
|
+
|
|
190
195
|
if (matchRoute('/users')) {
|
|
191
196
|
return <Users onNavigate={navigate} />;
|
|
192
197
|
}
|
|
@@ -35,7 +35,7 @@ interface Submission {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function sourceColor(source: string): string {
|
|
38
|
-
const s = source.toLowerCase();
|
|
38
|
+
const s = (source ?? '').toLowerCase();
|
|
39
39
|
if (s === 'google') return 'bg-blue-100 text-blue-800';
|
|
40
40
|
if (s === 'facebook' || s === 'meta') return 'bg-indigo-100 text-indigo-800';
|
|
41
41
|
if (s === 'linkedin') return 'bg-sky-100 text-sky-800';
|
|
@@ -45,7 +45,7 @@ function sourceColor(source: string): string {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
function mediumColor(medium: string): string {
|
|
48
|
-
const m = medium.toLowerCase();
|
|
48
|
+
const m = (medium ?? '').toLowerCase();
|
|
49
49
|
if (m === 'cpc' || m === 'paid') return 'bg-orange-100 text-orange-800';
|
|
50
50
|
if (m === 'organic') return 'bg-green-100 text-green-800';
|
|
51
51
|
if (m === 'social') return 'bg-pink-100 text-pink-800';
|
|
@@ -66,22 +66,22 @@ export interface FormSubmissionsProps {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
69
|
-
const { data, loading, error, refetch } = useApiData<Submission[]>(`/forms/${formId}/submissions`);
|
|
69
|
+
const { data, loading, error, refetch } = useApiData<Submission[] | { submissions: Submission[] }>(`/forms/${formId}/submissions`);
|
|
70
70
|
const [searchQuery, setSearchQuery] = useState('');
|
|
71
71
|
const [filterStatus, setFilterStatus] = useState('all');
|
|
72
72
|
const [filterSource, setFilterSource] = useState('all');
|
|
73
73
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
|
74
74
|
|
|
75
|
-
const submissions = data ?? [];
|
|
75
|
+
const submissions = Array.isArray(data) ? data : data?.submissions ?? [];
|
|
76
76
|
|
|
77
77
|
const filteredSubmissions = useMemo(() => {
|
|
78
78
|
let results = submissions.filter((s) => {
|
|
79
79
|
const matchesSearch =
|
|
80
|
-
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
81
|
-
s.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
82
|
-
s.message.toLowerCase().includes(searchQuery.toLowerCase());
|
|
80
|
+
(s.name ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
81
|
+
(s.email ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
82
|
+
(s.message ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
|
83
83
|
const matchesStatus = filterStatus === 'all' || s.status === filterStatus;
|
|
84
|
-
const matchesSource = filterSource === 'all' || s.attribution
|
|
84
|
+
const matchesSource = filterSource === 'all' || s.attribution?.source === filterSource;
|
|
85
85
|
return matchesSearch && matchesStatus && matchesSource;
|
|
86
86
|
});
|
|
87
87
|
|
|
@@ -91,19 +91,19 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
|
91
91
|
return results;
|
|
92
92
|
}, [submissions, searchQuery, filterStatus, filterSource]);
|
|
93
93
|
|
|
94
|
-
const uniqueSources = [...new Set(submissions.map(s => s.attribution
|
|
94
|
+
const uniqueSources = [...new Set(submissions.map(s => s.attribution?.source ?? '(direct)'))];
|
|
95
95
|
|
|
96
96
|
const sourceSummary = useMemo(() => {
|
|
97
97
|
const counts: Record<string, number> = {};
|
|
98
98
|
for (const s of submissions) {
|
|
99
|
-
const key = `${s.attribution
|
|
99
|
+
const key = `${s.attribution?.source ?? '(direct)'} / ${s.attribution?.medium ?? '(none)'}`;
|
|
100
100
|
counts[key] = (counts[key] ?? 0) + 1;
|
|
101
101
|
}
|
|
102
102
|
return Object.entries(counts).sort(([, a], [, b]) => b - a);
|
|
103
103
|
}, [submissions]);
|
|
104
104
|
|
|
105
|
-
const paidCount = submissions.filter(s => ['cpc', 'paid'].includes(s.attribution
|
|
106
|
-
const organicCount = submissions.filter(s => s.attribution
|
|
105
|
+
const paidCount = submissions.filter(s => ['cpc', 'paid'].includes(s.attribution?.medium ?? '')).length;
|
|
106
|
+
const organicCount = submissions.filter(s => s.attribution?.medium === 'organic').length;
|
|
107
107
|
|
|
108
108
|
const handleExport = () => {
|
|
109
109
|
toast.success('Submissions exported to CSV (includes attribution data)');
|
package/src/views/Forms.tsx
CHANGED
|
@@ -17,7 +17,7 @@ export function Forms({ onNavigate }: FormsProps) {
|
|
|
17
17
|
|
|
18
18
|
const filteredAndSorted = useMemo(() => {
|
|
19
19
|
let results = forms.filter((form: any) =>
|
|
20
|
-
form.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
20
|
+
(form.name ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
21
21
|
(form.description ?? '').toLowerCase().includes(searchQuery.toLowerCase())
|
|
22
22
|
);
|
|
23
23
|
|
|
@@ -15,7 +15,7 @@ import { FolderTree, type FolderSelection } from '../components/FolderTree.js';
|
|
|
15
15
|
import { FocalPointPicker } from '../components/FocalPointPicker.js';
|
|
16
16
|
|
|
17
17
|
interface MediaItem {
|
|
18
|
-
id: number;
|
|
18
|
+
id: number | string;
|
|
19
19
|
name: string;
|
|
20
20
|
type: string;
|
|
21
21
|
size: string;
|
|
@@ -29,6 +29,33 @@ interface MediaItem {
|
|
|
29
29
|
usedOn?: { page: string; path: string }[];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function isImageMedia(item: MediaItem): boolean {
|
|
33
|
+
return Boolean(item.url) && (item.type?.startsWith('image/') || /\.(avif|gif|jpe?g|png|webp|svg)$/i.test(item.url));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function matchesMediaType(item: MediaItem, filterType: string): boolean {
|
|
37
|
+
if (filterType === 'all') return true;
|
|
38
|
+
if (filterType === 'image') return item.type?.startsWith('image/') || isImageMedia(item);
|
|
39
|
+
if (filterType === 'video') return item.type?.startsWith('video/');
|
|
40
|
+
if (filterType === 'document') return !item.type?.startsWith('image/') && !item.type?.startsWith('video/');
|
|
41
|
+
return item.type === filterType;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function MediaPreview({ item }: { item: MediaItem }) {
|
|
45
|
+
if (isImageMedia(item)) {
|
|
46
|
+
return (
|
|
47
|
+
<img
|
|
48
|
+
src={item.url}
|
|
49
|
+
alt={item.altTag || item.title || item.name || 'Media preview'}
|
|
50
|
+
className="h-full w-full object-cover"
|
|
51
|
+
loading="lazy"
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return <FileImage className="w-6 h-6 sm:w-8 sm:h-8 text-muted-foreground" />;
|
|
57
|
+
}
|
|
58
|
+
|
|
32
59
|
type MediaSortKey = 'name' | 'type' | 'size' | 'date';
|
|
33
60
|
|
|
34
61
|
export interface MediaBrowserProps {
|
|
@@ -50,7 +77,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
50
77
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
51
78
|
|
|
52
79
|
const apiUrl = useMemo(() => buildMediaApiUrl(folderSel), [folderSel]);
|
|
53
|
-
const { data, loading, error, refetch } = useApiData<{ data
|
|
80
|
+
const { data, loading, error, refetch } = useApiData<{ data?: MediaItem[]; items?: MediaItem[]; total: number }>(apiUrl);
|
|
54
81
|
|
|
55
82
|
const allData = useApiData<{ data: MediaItem[]; total: number }>('/media?pageSize=1');
|
|
56
83
|
const uncatData = useApiData<{ data: MediaItem[]; total: number }>('/media?pageSize=1&folderId=none');
|
|
@@ -58,7 +85,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
58
85
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
|
59
86
|
const [searchQuery, setSearchQuery] = useState('');
|
|
60
87
|
const [filterType, setFilterType] = useState('all');
|
|
61
|
-
const [selectedMedia, setSelectedMedia] = useState<number
|
|
88
|
+
const [selectedMedia, setSelectedMedia] = useState<Array<number | string>>([]);
|
|
62
89
|
const [sortConfig, setSortConfig] = useState<SortConfig<MediaSortKey> | null>(null);
|
|
63
90
|
const [activeItem, setActiveItem] = useState<MediaItem | null>(null);
|
|
64
91
|
|
|
@@ -72,12 +99,12 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
72
99
|
const [uploading, setUploading] = useState(false);
|
|
73
100
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
74
101
|
|
|
75
|
-
const mediaItems = data?.data ?? [];
|
|
102
|
+
const mediaItems = data?.data ?? data?.items ?? [];
|
|
76
103
|
|
|
77
104
|
const filteredAndSorted = useMemo(() => {
|
|
78
105
|
let results = mediaItems.filter((item) => {
|
|
79
|
-
const matchesSearch = item.name.toLowerCase().includes(searchQuery.toLowerCase());
|
|
80
|
-
const matchesType =
|
|
106
|
+
const matchesSearch = (item.name ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
|
107
|
+
const matchesType = matchesMediaType(item, filterType);
|
|
81
108
|
return matchesSearch && matchesType;
|
|
82
109
|
});
|
|
83
110
|
|
|
@@ -110,7 +137,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
110
137
|
setActiveItem(null);
|
|
111
138
|
};
|
|
112
139
|
|
|
113
|
-
const handleCheckbox = (e: React.MouseEvent, id: number) => {
|
|
140
|
+
const handleCheckbox = (e: React.MouseEvent, id: number | string) => {
|
|
114
141
|
e.stopPropagation();
|
|
115
142
|
setSelectedMedia(prev => prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]);
|
|
116
143
|
};
|
|
@@ -141,7 +168,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
141
168
|
}
|
|
142
169
|
};
|
|
143
170
|
|
|
144
|
-
const deleteMedia = async (id: number) => {
|
|
171
|
+
const deleteMedia = async (id: number | string) => {
|
|
145
172
|
const res = await cmsApi(`/media/${id}`, { method: 'DELETE' });
|
|
146
173
|
if (res.error) {
|
|
147
174
|
toast.error(res.error);
|
|
@@ -185,7 +212,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
185
212
|
|
|
186
213
|
await new Promise(r => setTimeout(r, 1500));
|
|
187
214
|
if (field === 'alt') {
|
|
188
|
-
const generated = `A ${activeItem?.format
|
|
215
|
+
const generated = `A ${(activeItem?.format ?? 'media').toLowerCase()} image showing ${(activeItem?.name ?? 'uploaded').replace(/[-_]/g, ' ').replace(/\.\w+$/, '')} content`;
|
|
189
216
|
setEditAlt(generated);
|
|
190
217
|
toast.success('Alt tag generated by AI');
|
|
191
218
|
} else if (field === 'title') {
|
|
@@ -257,7 +284,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
257
284
|
}
|
|
258
285
|
}, [refetch]);
|
|
259
286
|
|
|
260
|
-
const handleDragStart = (e: React.DragEvent, id: number) => {
|
|
287
|
+
const handleDragStart = (e: React.DragEvent, id: number | string) => {
|
|
261
288
|
e.dataTransfer.setData('text/actuate-item-id', String(id));
|
|
262
289
|
e.dataTransfer.effectAllowed = 'move';
|
|
263
290
|
};
|
|
@@ -432,7 +459,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
432
459
|
onDragStart={(e) => handleDragStart(e, item.id)}
|
|
433
460
|
>
|
|
434
461
|
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
|
435
|
-
<
|
|
462
|
+
<MediaPreview item={item} />
|
|
436
463
|
</div>
|
|
437
464
|
<div className="absolute inset-0 bg-linear-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
|
438
465
|
<div className="absolute bottom-0 left-0 right-0 p-2">
|
|
@@ -494,7 +521,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
494
521
|
</td>
|
|
495
522
|
<td className="px-3 py-2">
|
|
496
523
|
<div className="flex items-center gap-3">
|
|
497
|
-
<div className="w-10 h-10 bg-gray-100 rounded flex items-center justify-center"><
|
|
524
|
+
<div className="w-10 h-10 bg-gray-100 rounded flex items-center justify-center overflow-hidden"><MediaPreview item={item} /></div>
|
|
498
525
|
<span className="text-sm font-medium text-gray-900">{item.name}</span>
|
|
499
526
|
</div>
|
|
500
527
|
</td>
|
|
@@ -530,8 +557,12 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
530
557
|
|
|
531
558
|
<div className="flex-1 overflow-y-auto">
|
|
532
559
|
<div className="p-4 border-b border-gray-200">
|
|
533
|
-
<div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center">
|
|
534
|
-
|
|
560
|
+
<div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
|
|
561
|
+
{isImageMedia(activeItem) ? (
|
|
562
|
+
<MediaPreview item={activeItem} />
|
|
563
|
+
) : (
|
|
564
|
+
<ImageIcon className="w-12 h-12 text-gray-300" />
|
|
565
|
+
)}
|
|
535
566
|
</div>
|
|
536
567
|
</div>
|
|
537
568
|
|
|
@@ -661,7 +692,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
661
692
|
</div>
|
|
662
693
|
</div>
|
|
663
694
|
|
|
664
|
-
{activeItem
|
|
695
|
+
{isImageMedia(activeItem) && activeItem.url && (
|
|
665
696
|
<div className="p-4 border-b border-gray-200">
|
|
666
697
|
<FocalPointPicker
|
|
667
698
|
imageUrl={activeItem.url}
|
package/src/views/Posts.tsx
CHANGED
|
@@ -25,7 +25,7 @@ export function Posts({ onNavigate }: PostsProps) {
|
|
|
25
25
|
|
|
26
26
|
const filteredAndSorted = useMemo(() => {
|
|
27
27
|
let results = posts.filter((post: any) => {
|
|
28
|
-
const matchesSearch = post.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
28
|
+
const matchesSearch = (post.title ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
29
29
|
(post.author ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
|
30
30
|
const matchesStatus = filterStatus === 'all' || (post.status ?? '').toLowerCase() === filterStatus.toLowerCase();
|
|
31
31
|
const matchesCategory = filterCategory === 'all' || (post.category ?? '').toLowerCase() === filterCategory.toLowerCase();
|
package/src/views/Redirects.tsx
CHANGED
|
@@ -27,8 +27,8 @@ export function Redirects({ onNavigate }: RedirectsProps) {
|
|
|
27
27
|
const filteredAndSorted = useMemo(() => {
|
|
28
28
|
let results = redirects.filter((r) => {
|
|
29
29
|
const matchesSearch =
|
|
30
|
-
r.from.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
31
|
-
r.to.toLowerCase().includes(searchQuery.toLowerCase());
|
|
30
|
+
(r.from ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
31
|
+
(r.to ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
|
32
32
|
const matchesType = filterType === 'all' || r.type === filterType;
|
|
33
33
|
return matchesSearch && matchesType;
|
|
34
34
|
});
|
package/src/views/SEO.tsx
CHANGED
|
@@ -44,7 +44,7 @@ export function SEO({ onNavigate, initialTab = 'pages' }: SEOProps) {
|
|
|
44
44
|
// --- Pages tab data ---
|
|
45
45
|
const filtered = seoPages
|
|
46
46
|
.filter((page: any) => {
|
|
47
|
-
const matchesSearch = page.url.toLowerCase().includes(searchQuery.toLowerCase()) || page.title.toLowerCase().includes(searchQuery.toLowerCase());
|
|
47
|
+
const matchesSearch = (page.url ?? '').toLowerCase().includes(searchQuery.toLowerCase()) || (page.title ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
|
48
48
|
const matchesScore = filterScore === 'all' || (filterScore === 'good' && page.score >= 80) || (filterScore === 'warning' && page.score >= 50 && page.score < 80) || (filterScore === 'critical' && page.score < 50);
|
|
49
49
|
return matchesSearch && matchesScore;
|
|
50
50
|
})
|
|
@@ -62,8 +62,8 @@ export function SEO({ onNavigate, initialTab = 'pages' }: SEOProps) {
|
|
|
62
62
|
|
|
63
63
|
// --- Redirects data ---
|
|
64
64
|
const filteredRedirects = redirects.filter((r: any) =>
|
|
65
|
-
r.from.toLowerCase().includes(redirectSearch.toLowerCase()) ||
|
|
66
|
-
r.to.toLowerCase().includes(redirectSearch.toLowerCase())
|
|
65
|
+
(r.from ?? '').toLowerCase().includes(redirectSearch.toLowerCase()) ||
|
|
66
|
+
(r.to ?? '').toLowerCase().includes(redirectSearch.toLowerCase())
|
|
67
67
|
);
|
|
68
68
|
|
|
69
69
|
const handleScan = async () => {
|
package/src/views/Users.tsx
CHANGED
|
@@ -28,9 +28,9 @@ export function Users({ onNavigate }: UsersProps) {
|
|
|
28
28
|
const filteredAndSorted = useMemo(() => {
|
|
29
29
|
let results = users.filter((user: any) => {
|
|
30
30
|
const matchesSearch =
|
|
31
|
-
user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
32
|
-
user.email.toLowerCase().includes(searchQuery.toLowerCase());
|
|
33
|
-
const matchesRole = filterRole === 'all' || user.role.toLowerCase() === filterRole.toLowerCase();
|
|
31
|
+
(user.name ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
32
|
+
(user.email ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
|
33
|
+
const matchesRole = filterRole === 'all' || (user.role ?? '').toLowerCase() === filterRole.toLowerCase();
|
|
34
34
|
return matchesSearch && matchesRole;
|
|
35
35
|
});
|
|
36
36
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, Layers, Loader2, RefreshCw } from 'lucide-react';
|
|
4
|
+
import { useApiData } from '../../lib/useApiData.js';
|
|
5
|
+
|
|
6
|
+
interface PageTemplate {
|
|
7
|
+
id: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
description?: string | null;
|
|
10
|
+
category?: string;
|
|
11
|
+
builtIn?: boolean;
|
|
12
|
+
updatedAt?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PageTemplatesProps {
|
|
16
|
+
onNavigate?: (path: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function PageTemplates({ onNavigate }: PageTemplatesProps) {
|
|
20
|
+
const { data, loading, error, refetch } = useApiData<PageTemplate[]>('/page-templates');
|
|
21
|
+
const templates = data ?? [];
|
|
22
|
+
|
|
23
|
+
if (loading) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex h-64 items-center justify-center p-4" role="status" aria-live="polite">
|
|
26
|
+
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
27
|
+
<span className="sr-only">Loading page templates</span>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="p-4 pr-8">
|
|
34
|
+
{error && (
|
|
35
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-border bg-card p-3">
|
|
36
|
+
<AlertTriangle className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
37
|
+
<span className="flex-1 text-sm text-foreground">{error}</span>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={refetch}
|
|
41
|
+
className="rounded-md border border-border px-3 py-1 text-sm text-foreground hover:bg-accent"
|
|
42
|
+
>
|
|
43
|
+
Retry
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
<div className="mb-4 flex items-center justify-between">
|
|
49
|
+
<div>
|
|
50
|
+
<h1 className="mb-1 text-2xl font-medium text-foreground">Page Templates</h1>
|
|
51
|
+
<p className="text-sm text-muted-foreground">
|
|
52
|
+
{templates.length} saved template{templates.length === 1 ? '' : 's'}
|
|
53
|
+
</p>
|
|
54
|
+
</div>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onClick={refetch}
|
|
58
|
+
className="inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-accent"
|
|
59
|
+
>
|
|
60
|
+
<RefreshCw className="h-4 w-4" />
|
|
61
|
+
Refresh
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{templates.length === 0 ? (
|
|
66
|
+
<div className="rounded-lg border border-border bg-card p-8 text-center">
|
|
67
|
+
<Layers className="mx-auto mb-3 h-8 w-8 text-muted-foreground" />
|
|
68
|
+
<h2 className="mb-1 text-lg font-medium text-foreground">No page templates yet</h2>
|
|
69
|
+
<p className="mb-4 text-sm text-muted-foreground">
|
|
70
|
+
Built-in templates are seeded by the CMS when the templates API is available.
|
|
71
|
+
</p>
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={() => onNavigate?.('/saved-sections')}
|
|
75
|
+
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
|
76
|
+
>
|
|
77
|
+
View Saved Sections
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
) : (
|
|
81
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
82
|
+
{templates.map((template) => (
|
|
83
|
+
<div key={template.id} className="rounded-lg border border-border bg-card p-4">
|
|
84
|
+
<div className="mb-3 flex items-start justify-between gap-3">
|
|
85
|
+
<div>
|
|
86
|
+
<h2 className="text-base font-medium text-foreground">{template.name ?? 'Untitled template'}</h2>
|
|
87
|
+
<p className="mt-1 text-sm text-muted-foreground">{template.description ?? 'No description provided.'}</p>
|
|
88
|
+
</div>
|
|
89
|
+
{template.builtIn && (
|
|
90
|
+
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
91
|
+
Built-in
|
|
92
|
+
</span>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
96
|
+
<span>{template.category ?? 'content'}</span>
|
|
97
|
+
<span>{template.updatedAt ? new Date(template.updatedAt).toLocaleDateString() : ''}</span>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|