@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.7.0",
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.9.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.source === filterSource;
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.source))];
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.source} / ${s.attribution.medium}`;
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.medium)).length;
106
- const organicCount = submissions.filter(s => s.attribution.medium === 'organic').length;
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)');
@@ -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: MediaItem[]; total: number }>(apiUrl);
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 = filterType === 'all' || item.type === filterType;
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?.toLowerCase()} image showing ${activeItem?.name.replace(/[-_]/g, ' ').replace(/\.\w+$/, '')} content`;
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
- <FileImage className="w-6 h-6 sm:w-8 sm:h-8 text-gray-400" />
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"><FileImage className="w-5 h-5 text-gray-400" /></div>
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
- <ImageIcon className="w-12 h-12 text-gray-300" />
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.type === 'image' && activeItem.url && (
695
+ {isImageMedia(activeItem) && activeItem.url && (
665
696
  <div className="p-4 border-b border-gray-200">
666
697
  <FocalPointPicker
667
698
  imageUrl={activeItem.url}
@@ -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();
@@ -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 () => {
@@ -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
+ }