@actuate-media/cms-admin 0.1.3 → 0.2.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 (92) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +16 -10
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/actuate-admin.css +2 -0
  5. package/dist/lib/useApiData.d.ts +8 -1
  6. package/dist/lib/useApiData.d.ts.map +1 -1
  7. package/dist/lib/useApiData.js +39 -7
  8. package/dist/lib/useApiData.js.map +1 -1
  9. package/dist/views/Dashboard.d.ts.map +1 -1
  10. package/dist/views/Dashboard.js +8 -3
  11. package/dist/views/Dashboard.js.map +1 -1
  12. package/package.json +10 -5
  13. package/src/AdminRoot.tsx +312 -0
  14. package/src/__tests__/lib/search.test.ts +138 -0
  15. package/src/__tests__/lib/utils.test.ts +19 -0
  16. package/src/__tests__/router/match-route.test.ts +47 -0
  17. package/src/__tests__/router/strip-base.test.ts +30 -0
  18. package/src/components/Breadcrumbs.tsx +92 -0
  19. package/src/components/CommandPalette.tsx +384 -0
  20. package/src/components/ErrorBoundary.tsx +52 -0
  21. package/src/components/FocalPointPicker.tsx +54 -0
  22. package/src/components/FolderTree.tsx +427 -0
  23. package/src/components/LivePreview.tsx +136 -0
  24. package/src/components/LocaleProvider.tsx +51 -0
  25. package/src/components/LocaleSwitcher.tsx +51 -0
  26. package/src/components/MediaPickerModal.tsx +183 -0
  27. package/src/components/PresenceIndicator.tsx +71 -0
  28. package/src/components/SEOPanel.tsx +767 -0
  29. package/src/components/ThemeProvider.tsx +98 -0
  30. package/src/components/TipTapEditor.tsx +469 -0
  31. package/src/components/VersionHistory.tsx +167 -0
  32. package/src/components/ui/Avatar.tsx +42 -0
  33. package/src/components/ui/Badge.tsx +25 -0
  34. package/src/components/ui/Button.tsx +52 -0
  35. package/src/components/ui/CommandPalette.tsx +119 -0
  36. package/src/components/ui/ConfirmDialog.tsx +52 -0
  37. package/src/components/ui/DataTable.tsx +194 -0
  38. package/src/components/ui/EmptyState.tsx +29 -0
  39. package/src/components/ui/Modal.tsx +48 -0
  40. package/src/components/ui/Pagination.tsx +79 -0
  41. package/src/components/ui/SearchInput.tsx +44 -0
  42. package/src/components/ui/Skeleton.tsx +48 -0
  43. package/src/components/ui/Toast.tsx +66 -0
  44. package/src/components/ui/index.ts +24 -0
  45. package/src/fields/ArrayField.tsx +92 -0
  46. package/src/fields/BlockBuilderField.tsx +421 -0
  47. package/src/fields/DateField.tsx +41 -0
  48. package/src/fields/FieldRenderer.tsx +84 -0
  49. package/src/fields/GroupField.tsx +41 -0
  50. package/src/fields/MediaField.tsx +48 -0
  51. package/src/fields/NavBuilderField.tsx +78 -0
  52. package/src/fields/NumberField.tsx +45 -0
  53. package/src/fields/RelationshipField.tsx +245 -0
  54. package/src/fields/RichTextField.tsx +26 -0
  55. package/src/fields/SelectField.tsx +117 -0
  56. package/src/fields/SlugField.tsx +65 -0
  57. package/src/fields/TextField.tsx +48 -0
  58. package/src/fields/ToggleField.tsx +36 -0
  59. package/src/fields/block-types.ts +95 -0
  60. package/src/fields/index.ts +17 -0
  61. package/src/hooks/useContentLock.ts +52 -0
  62. package/src/hooks/useDebounce.ts +14 -0
  63. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  64. package/src/index.ts +55 -0
  65. package/src/layout/Header.tsx +135 -0
  66. package/src/layout/Layout.tsx +77 -0
  67. package/src/layout/Sidebar.tsx +216 -0
  68. package/src/lib/api.ts +67 -0
  69. package/src/lib/search.ts +59 -0
  70. package/src/lib/useApiData.ts +95 -0
  71. package/src/lib/utils.ts +6 -0
  72. package/src/router/index.ts +81 -0
  73. package/src/styles/build-input.css +11 -0
  74. package/src/styles/tailwind.css +7 -2
  75. package/src/styles/theme.css +2 -1
  76. package/src/views/CollectionList.tsx +270 -0
  77. package/src/views/Dashboard.tsx +207 -0
  78. package/src/views/DocumentEdit.tsx +377 -0
  79. package/src/views/FormEditor.tsx +533 -0
  80. package/src/views/FormSubmissions.tsx +316 -0
  81. package/src/views/Forms.tsx +106 -0
  82. package/src/views/Login.tsx +322 -0
  83. package/src/views/MediaBrowser.tsx +774 -0
  84. package/src/views/PageEditor.tsx +192 -0
  85. package/src/views/Pages.tsx +354 -0
  86. package/src/views/PostEditor.tsx +251 -0
  87. package/src/views/Posts.tsx +243 -0
  88. package/src/views/Redirects.tsx +293 -0
  89. package/src/views/SEO.tsx +458 -0
  90. package/src/views/Settings.tsx +811 -0
  91. package/src/views/SetupWizard.tsx +207 -0
  92. package/src/views/Users.tsx +282 -0
@@ -0,0 +1,270 @@
1
+ 'use client';
2
+
3
+ import { Search, Plus, Trash2, ChevronLeft, ChevronRight, Loader2, FileText, MoreHorizontal } from 'lucide-react';
4
+ import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
5
+ import { toast } from 'sonner';
6
+ import { cmsApi } from '../lib/api.js';
7
+ import { useApiData } from '../lib/useApiData.js';
8
+
9
+ export interface CollectionListProps {
10
+ collectionSlug: string;
11
+ config: any;
12
+ onNavigate: (path: string) => void;
13
+ }
14
+
15
+ type SortField = 'title' | 'status' | 'updatedAt';
16
+ type SortOrder = 'asc' | 'desc';
17
+
18
+ function resolveLabel(config: any, slug: string): { singular: string; plural: string } {
19
+ const fallback = { singular: slug, plural: slug };
20
+ if (!config?.collections) return fallback;
21
+ const list = Array.isArray(config.collections)
22
+ ? config.collections
23
+ : Object.values(config.collections);
24
+ const match = (list as any[]).find((c: any) => c.slug === slug);
25
+ return match?.labels ?? fallback;
26
+ }
27
+
28
+ function statusColor(status: string): string {
29
+ switch (status?.toUpperCase()) {
30
+ case 'PUBLISHED': return 'var(--actuate-success-bg, #dcfce7)';
31
+ case 'DRAFT': return 'var(--actuate-muted-bg, #f3f4f6)';
32
+ case 'ARCHIVED': return 'var(--actuate-warning-bg, #fef9c3)';
33
+ case 'SCHEDULED': return 'var(--actuate-info-bg, #dbeafe)';
34
+ default: return 'var(--actuate-muted-bg, #f3f4f6)';
35
+ }
36
+ }
37
+
38
+ function statusText(status: string): string {
39
+ switch (status?.toUpperCase()) {
40
+ case 'PUBLISHED': return 'var(--actuate-success-text, #166534)';
41
+ case 'DRAFT': return 'var(--actuate-muted-text, #374151)';
42
+ case 'ARCHIVED': return 'var(--actuate-warning-text, #854d0e)';
43
+ case 'SCHEDULED': return 'var(--actuate-info-text, #1e40af)';
44
+ default: return 'var(--actuate-muted-text, #374151)';
45
+ }
46
+ }
47
+
48
+ function formatDate(d: string | undefined): string {
49
+ if (!d) return '—';
50
+ return new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
51
+ }
52
+
53
+ export function CollectionList({ collectionSlug, config, onNavigate }: CollectionListProps) {
54
+ const labels = useMemo(() => resolveLabel(config, collectionSlug), [config, collectionSlug]);
55
+
56
+ const [page, setPage] = useState(1);
57
+ const [search, setSearch] = useState('');
58
+ const [debouncedSearch, setDebouncedSearch] = useState('');
59
+ const [sort, setSort] = useState<SortField | null>(null);
60
+ const [order, setOrder] = useState<SortOrder>('asc');
61
+ const [selected, setSelected] = useState<Set<string>>(new Set());
62
+ const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
63
+
64
+ useEffect(() => {
65
+ clearTimeout(searchTimer.current);
66
+ searchTimer.current = setTimeout(() => {
67
+ setDebouncedSearch(search);
68
+ setPage(1);
69
+ }, 300);
70
+ return () => clearTimeout(searchTimer.current);
71
+ }, [search]);
72
+
73
+ const endpoint = useMemo(() => {
74
+ let url = `/collections/${collectionSlug}?page=${page}&pageSize=25`;
75
+ if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`;
76
+ if (sort) url += `&sort=${sort}&order=${order}`;
77
+ return url;
78
+ }, [collectionSlug, page, debouncedSearch, sort, order]);
79
+
80
+ const { data, loading, error, refetch } = useApiData<{ docs: any[]; total: number }>(endpoint);
81
+ const docs = data?.docs ?? [];
82
+ const total = data?.total ?? 0;
83
+ const totalPages = Math.max(1, Math.ceil(total / 25));
84
+
85
+ useEffect(() => { setPage(1); setSelected(new Set()); setSearch(''); }, [collectionSlug]);
86
+
87
+ const toggleSort = useCallback((field: SortField) => {
88
+ setSort(prev => {
89
+ if (prev === field) {
90
+ setOrder(o => o === 'asc' ? 'desc' : 'asc');
91
+ return field;
92
+ }
93
+ setOrder('asc');
94
+ return field;
95
+ });
96
+ }, []);
97
+
98
+ const toggleSelect = (id: string) =>
99
+ setSelected(prev => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
100
+
101
+ const toggleAll = (checked: boolean) =>
102
+ setSelected(checked ? new Set(docs.map((d: any) => String(d.id))) : new Set());
103
+
104
+ const bulkAction = async (action: 'delete' | 'publish' | 'unpublish') => {
105
+ const ids = [...selected];
106
+ for (const id of ids) {
107
+ if (action === 'delete') {
108
+ await cmsApi(`/collections/${collectionSlug}/${id}`, { method: 'DELETE' });
109
+ } else {
110
+ await cmsApi(`/collections/${collectionSlug}/${id}`, {
111
+ method: 'PUT',
112
+ body: JSON.stringify({ status: action === 'publish' ? 'PUBLISHED' : 'DRAFT' }),
113
+ });
114
+ }
115
+ }
116
+ const verb = action === 'delete' ? 'deleted' : action === 'publish' ? 'published' : 'unpublished';
117
+ toast.success(`${ids.length} ${ids.length === 1 ? labels.singular : labels.plural} ${verb}`);
118
+ setSelected(new Set());
119
+ refetch();
120
+ };
121
+
122
+ const SortCol = ({ field, children }: { field: SortField; children: string }) => (
123
+ <button type="button" onClick={() => toggleSort(field)}
124
+ className="flex items-center gap-1 text-xs font-medium hover:opacity-80 transition-opacity"
125
+ style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}>
126
+ {children}
127
+ {sort === field && <span>{order === 'asc' ? '↑' : '↓'}</span>}
128
+ </button>
129
+ );
130
+
131
+ if (loading && docs.length === 0) {
132
+ return (
133
+ <div className="p-4 flex items-center justify-center h-64">
134
+ <Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--actuate-primary, #2563eb)' }} />
135
+ </div>
136
+ );
137
+ }
138
+
139
+ return (
140
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 h-full flex flex-col">
141
+ {/* Header */}
142
+ <div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
143
+ <div>
144
+ <h1 className="text-xl sm:text-2xl font-semibold" style={{ color: 'var(--actuate-text, #111827)' }}>
145
+ {labels.plural}
146
+ </h1>
147
+ <p className="text-sm" style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}>{total} total</p>
148
+ </div>
149
+ <button onClick={() => onNavigate(`/${collectionSlug}/new`)}
150
+ className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors"
151
+ style={{ background: 'var(--actuate-primary, #2563eb)' }}>
152
+ <Plus className="w-4 h-4" /> New {labels.singular}
153
+ </button>
154
+ </div>
155
+
156
+ {/* Search */}
157
+ <div className="relative mb-4">
158
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--actuate-text-muted, #9ca3af)' }} />
159
+ <input type="text" placeholder={`Search ${labels.plural.toLowerCase()}...`} value={search}
160
+ onChange={e => setSearch(e.target.value)}
161
+ className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border focus:outline-none focus:ring-2"
162
+ style={{ borderColor: 'var(--actuate-border, #d1d5db)', color: 'var(--actuate-text, #111827)' }} />
163
+ </div>
164
+
165
+ {/* Bulk actions */}
166
+ {selected.size > 0 && (
167
+ <div className="rounded-lg p-3 mb-4 flex flex-wrap items-center justify-between gap-2"
168
+ style={{ background: 'var(--actuate-info-bg, #eff6ff)', borderColor: 'var(--actuate-info-border, #bfdbfe)', borderWidth: 1, borderStyle: 'solid' }}>
169
+ <span className="text-sm" style={{ color: 'var(--actuate-info-text, #1e40af)' }}>
170
+ {selected.size} selected
171
+ </span>
172
+ <div className="flex gap-2">
173
+ <button onClick={() => bulkAction('publish')} className="px-3 py-1.5 text-sm text-white rounded-lg" style={{ background: 'var(--actuate-success, #16a34a)' }}>Publish</button>
174
+ <button onClick={() => bulkAction('unpublish')} className="px-3 py-1.5 text-sm text-white rounded-lg" style={{ background: 'var(--actuate-warning, #ca8a04)' }}>Unpublish</button>
175
+ <button onClick={() => bulkAction('delete')} className="px-3 py-1.5 text-sm text-white rounded-lg" style={{ background: 'var(--actuate-danger, #dc2626)' }}>
176
+ <Trash2 className="w-3.5 h-3.5 inline -mt-0.5 mr-1" />Delete
177
+ </button>
178
+ </div>
179
+ </div>
180
+ )}
181
+
182
+ {error && (
183
+ <div className="rounded-lg p-3 mb-4 text-sm" style={{ background: 'var(--actuate-danger-bg, #fef2f2)', color: 'var(--actuate-danger-text, #991b1b)' }}>
184
+ {error} — <button onClick={refetch} className="underline">retry</button>
185
+ </div>
186
+ )}
187
+
188
+ {/* Table */}
189
+ {docs.length === 0 && !loading ? (
190
+ <div className="flex-1 flex flex-col items-center justify-center rounded-lg border p-8"
191
+ style={{ borderColor: 'var(--actuate-border, #d1d5db)', color: 'var(--actuate-text-secondary, #6b7280)' }}>
192
+ <FileText className="w-10 h-10 mb-3 opacity-40" />
193
+ <p className="text-sm mb-3">No {labels.plural.toLowerCase()} found</p>
194
+ <button onClick={() => onNavigate(`/${collectionSlug}/new`)}
195
+ className="px-4 py-2 text-sm text-white rounded-lg" style={{ background: 'var(--actuate-primary, #2563eb)' }}>
196
+ Create {labels.singular}
197
+ </button>
198
+ </div>
199
+ ) : (
200
+ <div className="flex-1 rounded-lg border overflow-auto" style={{ borderColor: 'var(--actuate-border, #d1d5db)' }}>
201
+ <table className="w-full text-sm">
202
+ <thead className="sticky top-0" style={{ background: 'var(--actuate-surface-alt, #f9fafb)', borderBottom: '1px solid var(--actuate-border, #d1d5db)' }}>
203
+ <tr>
204
+ <th className="w-10 px-3 py-2 text-left">
205
+ <input type="checkbox" checked={selected.size === docs.length && docs.length > 0}
206
+ onChange={e => toggleAll(e.target.checked)} className="rounded" />
207
+ </th>
208
+ <th className="px-3 py-2 text-left"><SortCol field="title">Title</SortCol></th>
209
+ <th className="px-3 py-2 text-left"><SortCol field="status">Status</SortCol></th>
210
+ <th className="px-3 py-2 text-left"><SortCol field="updatedAt">Updated</SortCol></th>
211
+ <th className="px-3 py-2 text-left text-xs font-medium" style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}>Actions</th>
212
+ </tr>
213
+ </thead>
214
+ <tbody>
215
+ {docs.map((doc: any) => (
216
+ <tr key={doc.id} className="transition-colors hover:opacity-95"
217
+ style={{ borderBottom: '1px solid var(--actuate-border, #e5e7eb)' }}>
218
+ <td className="px-3 py-2">
219
+ <input type="checkbox" checked={selected.has(String(doc.id))}
220
+ onChange={() => toggleSelect(String(doc.id))} className="rounded" />
221
+ </td>
222
+ <td className="px-3 py-2">
223
+ <button type="button" onClick={() => onNavigate(`/${collectionSlug}/${doc.id}`)}
224
+ className="font-medium text-left hover:underline" style={{ color: 'var(--actuate-text, #111827)' }}>
225
+ {doc.title || doc.name || `#${doc.id}`}
226
+ </button>
227
+ </td>
228
+ <td className="px-3 py-2">
229
+ <span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
230
+ style={{ background: statusColor(doc.status), color: statusText(doc.status) }}>
231
+ {doc.status ?? 'DRAFT'}
232
+ </span>
233
+ </td>
234
+ <td className="px-3 py-2" style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}>
235
+ {formatDate(doc.updatedAt)}
236
+ </td>
237
+ <td className="px-3 py-2">
238
+ <button type="button" onClick={() => onNavigate(`/${collectionSlug}/${doc.id}`)}
239
+ className="p-1.5 rounded hover:opacity-75 transition-opacity" title="Edit">
240
+ <MoreHorizontal className="w-4 h-4" style={{ color: 'var(--actuate-text-secondary, #6b7280)' }} />
241
+ </button>
242
+ </td>
243
+ </tr>
244
+ ))}
245
+ </tbody>
246
+ </table>
247
+ </div>
248
+ )}
249
+
250
+ {/* Pagination */}
251
+ {totalPages > 1 && (
252
+ <div className="flex items-center justify-between mt-4 text-sm" style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}>
253
+ <span>Page {page} of {totalPages}</span>
254
+ <div className="flex gap-2">
255
+ <button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
256
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg border disabled:opacity-40 transition-opacity"
257
+ style={{ borderColor: 'var(--actuate-border, #d1d5db)' }}>
258
+ <ChevronLeft className="w-4 h-4" /> Previous
259
+ </button>
260
+ <button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}
261
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg border disabled:opacity-40 transition-opacity"
262
+ style={{ borderColor: 'var(--actuate-border, #d1d5db)' }}>
263
+ Next <ChevronRight className="w-4 h-4" />
264
+ </button>
265
+ </div>
266
+ </div>
267
+ )}
268
+ </div>
269
+ );
270
+ }
@@ -0,0 +1,207 @@
1
+ 'use client';
2
+
3
+ import { FileText, Layout, Image, Users, User, Calendar, Loader2, AlertTriangle, Database } from 'lucide-react';
4
+ import { useApiData } from '../lib/useApiData.js';
5
+
6
+ interface DashboardStats {
7
+ totalDocuments: number;
8
+ totalMedia: number;
9
+ totalUsers: number;
10
+ recentDocuments: { id: number; title: string; status: string; date: string; author: string }[];
11
+ }
12
+
13
+ interface HealthData {
14
+ status: 'healthy' | 'degraded';
15
+ version: string;
16
+ secretConfigured: boolean;
17
+ models: Record<string, boolean>;
18
+ databaseConnected: boolean;
19
+ }
20
+
21
+ export interface DashboardProps {
22
+ onNavigate?: (path: string) => void;
23
+ }
24
+
25
+ export function Dashboard({ onNavigate }: DashboardProps) {
26
+ const nav = (path: string) => onNavigate?.(path);
27
+ const { data, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats');
28
+ const { data: health } = useApiData<HealthData>('/health');
29
+
30
+ const totalPosts = data?.totalDocuments ?? 0;
31
+ const totalMedia = data?.totalMedia ?? 0;
32
+ const totalUsers = data?.totalUsers ?? 0;
33
+ const recentPosts = data?.recentDocuments ?? [];
34
+
35
+ if (loading) {
36
+ return (
37
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
38
+ <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
39
+ </div>
40
+ );
41
+ }
42
+
43
+ return (
44
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8">
45
+ {health && health.status === 'degraded' && (
46
+ <div className="mb-4 flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
47
+ <Database className="w-5 h-5 text-blue-600 shrink-0" />
48
+ <div className="flex-1">
49
+ <span className="text-sm font-medium text-blue-900">Database Setup Required</span>
50
+ <p className="text-xs text-blue-700 mt-0.5">
51
+ {!health.databaseConnected
52
+ ? 'Cannot connect to the database. Check your DATABASE_URL environment variable.'
53
+ : !health.secretConfigured
54
+ ? 'CMS secret not configured. Set CMS_SECRET or CMS_SESSION_SECRET (min 32 characters).'
55
+ : `Some CMS models are missing: ${Object.entries(health.models).filter(([, v]) => !v).map(([k]) => k).join(', ')}. Run your database migrations to create the required tables.`
56
+ }
57
+ </p>
58
+ </div>
59
+ </div>
60
+ )}
61
+
62
+ {error && exhausted && (
63
+ <div className="mb-4 flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
64
+ <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0" />
65
+ <span className="text-sm text-amber-800 flex-1">Some dashboard data may be unavailable. This is normal if your database hasn&apos;t been set up yet.</span>
66
+ <button onClick={refetch} className="px-3 py-1 text-sm text-amber-700 border border-amber-300 rounded-lg hover:bg-amber-100 transition-colors">Retry</button>
67
+ </div>
68
+ )}
69
+
70
+ <div className="mb-4 sm:mb-6">
71
+ <h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">Dashboard</h1>
72
+ <p className="text-sm text-gray-600">Welcome back! Here's what's happening.</p>
73
+ </div>
74
+
75
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4 sm:mb-6">
76
+ <div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
77
+ <div className="flex items-center justify-between">
78
+ <div>
79
+ <p className="text-xs text-gray-600 mb-1">Total Posts</p>
80
+ <p className="text-xl sm:text-2xl font-semibold text-gray-900">{totalPosts}</p>
81
+ <p className="text-xs text-gray-400 mt-1">—</p>
82
+ </div>
83
+ <div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-lg flex items-center justify-center">
84
+ <FileText className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
90
+ <div className="flex items-center justify-between">
91
+ <div>
92
+ <p className="text-xs text-gray-600 mb-1">Total Pages</p>
93
+ <p className="text-xl sm:text-2xl font-semibold text-gray-900">0</p>
94
+ <p className="text-xs text-gray-400 mt-1">—</p>
95
+ </div>
96
+ <div className="w-8 h-8 sm:w-10 sm:h-10 bg-purple-100 rounded-lg flex items-center justify-center">
97
+ <Layout className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ <div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
103
+ <div className="flex items-center justify-between">
104
+ <div>
105
+ <p className="text-xs text-gray-600 mb-1">Media Files</p>
106
+ <p className="text-xl sm:text-2xl font-semibold text-gray-900">{totalMedia.toLocaleString()}</p>
107
+ <p className="text-xs text-gray-400 mt-1">—</p>
108
+ </div>
109
+ <div className="w-8 h-8 sm:w-10 sm:h-10 bg-green-100 rounded-lg flex items-center justify-center">
110
+ <Image className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
111
+ </div>
112
+ </div>
113
+ </div>
114
+
115
+ <div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4">
116
+ <div className="flex items-center justify-between">
117
+ <div>
118
+ <p className="text-xs text-gray-600 mb-1">Total Users</p>
119
+ <p className="text-xl sm:text-2xl font-semibold text-gray-900">{totalUsers}</p>
120
+ <p className="text-xs text-gray-400 mt-1">—</p>
121
+ </div>
122
+ <div className="w-8 h-8 sm:w-10 sm:h-10 bg-amber-100 rounded-lg flex items-center justify-center">
123
+ <Users className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600" />
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-3 sm:gap-4">
130
+ <div className="lg:col-span-8 bg-white rounded-lg border border-gray-200">
131
+ <div className="p-3 sm:p-4 border-b border-gray-200">
132
+ <h2 className="text-sm sm:text-base font-semibold text-gray-900">Recent Posts</h2>
133
+ </div>
134
+ <div className="divide-y divide-gray-200">
135
+ {recentPosts.length === 0 ? (
136
+ <div className="p-8 text-center">
137
+ <p className="text-sm text-gray-500 mb-2">No posts yet</p>
138
+ <button onClick={() => nav('/posts/new')} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Create your first post</button>
139
+ </div>
140
+ ) : recentPosts.map((post) => (
141
+ <div key={post.id} className="p-3 sm:p-4 hover:bg-gray-50 transition-colors">
142
+ <div className="flex items-start justify-between gap-3">
143
+ <div className="flex-1 min-w-0">
144
+ <h3 className="text-sm font-medium text-gray-900 mb-1 truncate">{post.title}</h3>
145
+ <div className="flex flex-wrap items-center gap-2 sm:gap-3 text-xs text-gray-600">
146
+ <span className="flex items-center gap-1">
147
+ <User className="w-3 h-3" />
148
+ <span className="hidden sm:inline">{post.author}</span>
149
+ </span>
150
+ <span className="flex items-center gap-1">
151
+ <Calendar className="w-3 h-3" />
152
+ {post.date}
153
+ </span>
154
+ <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
155
+ post.status === 'Published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
156
+ }`}>
157
+ {post.status}
158
+ </span>
159
+ </div>
160
+ </div>
161
+ <button
162
+ onClick={() => nav(`/posts/${post.id}`)}
163
+ className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 whitespace-nowrap"
164
+ >
165
+ Edit
166
+ </button>
167
+ </div>
168
+ </div>
169
+ ))}
170
+ </div>
171
+ </div>
172
+
173
+ <div className="lg:col-span-4 bg-white rounded-lg border border-gray-200">
174
+ <div className="p-3 sm:p-4 border-b border-gray-200">
175
+ <h2 className="text-sm sm:text-base font-semibold text-gray-900">Quick Actions</h2>
176
+ </div>
177
+ <div className="p-3 sm:p-4 space-y-2">
178
+ <button onClick={() => nav('/posts/new')} className="flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left">
179
+ <div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
180
+ <FileText className="w-4 h-4 text-blue-600" />
181
+ </div>
182
+ <span className="text-sm font-medium text-gray-900">New Post</span>
183
+ </button>
184
+ <button onClick={() => nav('/pages/new')} className="flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left">
185
+ <div className="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
186
+ <Layout className="w-4 h-4 text-purple-600" />
187
+ </div>
188
+ <span className="text-sm font-medium text-gray-900">New Page</span>
189
+ </button>
190
+ <button onClick={() => nav('/media')} className="flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left">
191
+ <div className="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
192
+ <Image className="w-4 h-4 text-green-600" />
193
+ </div>
194
+ <span className="text-sm font-medium text-gray-900">Upload Media</span>
195
+ </button>
196
+ <button onClick={() => nav('/users')} className="flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left">
197
+ <div className="w-8 h-8 bg-amber-100 rounded-lg flex items-center justify-center group-hover:bg-amber-200 transition-colors">
198
+ <Users className="w-4 h-4 text-amber-600" />
199
+ </div>
200
+ <span className="text-sm font-medium text-gray-900">Manage Users</span>
201
+ </button>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ );
207
+ }