@actuate-media/cms-admin 0.1.4 → 0.2.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 (95) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +17 -11
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/actuate-admin.css +2 -0
  6. package/dist/components/TipTapEditor.js +78 -78
  7. package/dist/lib/useApiData.d.ts +8 -1
  8. package/dist/lib/useApiData.d.ts.map +1 -1
  9. package/dist/lib/useApiData.js +39 -7
  10. package/dist/lib/useApiData.js.map +1 -1
  11. package/dist/views/Dashboard.d.ts +2 -1
  12. package/dist/views/Dashboard.d.ts.map +1 -1
  13. package/dist/views/Dashboard.js +52 -7
  14. package/dist/views/Dashboard.js.map +1 -1
  15. package/package.json +10 -5
  16. package/src/AdminRoot.tsx +312 -0
  17. package/src/__tests__/lib/search.test.ts +138 -0
  18. package/src/__tests__/lib/utils.test.ts +19 -0
  19. package/src/__tests__/router/match-route.test.ts +47 -0
  20. package/src/__tests__/router/strip-base.test.ts +30 -0
  21. package/src/components/Breadcrumbs.tsx +92 -0
  22. package/src/components/CommandPalette.tsx +384 -0
  23. package/src/components/ErrorBoundary.tsx +52 -0
  24. package/src/components/FocalPointPicker.tsx +54 -0
  25. package/src/components/FolderTree.tsx +427 -0
  26. package/src/components/LivePreview.tsx +136 -0
  27. package/src/components/LocaleProvider.tsx +51 -0
  28. package/src/components/LocaleSwitcher.tsx +51 -0
  29. package/src/components/MediaPickerModal.tsx +183 -0
  30. package/src/components/PresenceIndicator.tsx +71 -0
  31. package/src/components/SEOPanel.tsx +767 -0
  32. package/src/components/ThemeProvider.tsx +98 -0
  33. package/src/components/TipTapEditor.tsx +469 -0
  34. package/src/components/VersionHistory.tsx +167 -0
  35. package/src/components/ui/Avatar.tsx +42 -0
  36. package/src/components/ui/Badge.tsx +25 -0
  37. package/src/components/ui/Button.tsx +52 -0
  38. package/src/components/ui/CommandPalette.tsx +119 -0
  39. package/src/components/ui/ConfirmDialog.tsx +52 -0
  40. package/src/components/ui/DataTable.tsx +194 -0
  41. package/src/components/ui/EmptyState.tsx +29 -0
  42. package/src/components/ui/Modal.tsx +48 -0
  43. package/src/components/ui/Pagination.tsx +79 -0
  44. package/src/components/ui/SearchInput.tsx +44 -0
  45. package/src/components/ui/Skeleton.tsx +48 -0
  46. package/src/components/ui/Toast.tsx +66 -0
  47. package/src/components/ui/index.ts +24 -0
  48. package/src/fields/ArrayField.tsx +92 -0
  49. package/src/fields/BlockBuilderField.tsx +421 -0
  50. package/src/fields/DateField.tsx +41 -0
  51. package/src/fields/FieldRenderer.tsx +84 -0
  52. package/src/fields/GroupField.tsx +41 -0
  53. package/src/fields/MediaField.tsx +48 -0
  54. package/src/fields/NavBuilderField.tsx +78 -0
  55. package/src/fields/NumberField.tsx +45 -0
  56. package/src/fields/RelationshipField.tsx +245 -0
  57. package/src/fields/RichTextField.tsx +26 -0
  58. package/src/fields/SelectField.tsx +117 -0
  59. package/src/fields/SlugField.tsx +65 -0
  60. package/src/fields/TextField.tsx +48 -0
  61. package/src/fields/ToggleField.tsx +36 -0
  62. package/src/fields/block-types.ts +95 -0
  63. package/src/fields/index.ts +17 -0
  64. package/src/hooks/useContentLock.ts +52 -0
  65. package/src/hooks/useDebounce.ts +14 -0
  66. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  67. package/src/index.ts +55 -0
  68. package/src/layout/Header.tsx +135 -0
  69. package/src/layout/Layout.tsx +77 -0
  70. package/src/layout/Sidebar.tsx +216 -0
  71. package/src/lib/api.ts +67 -0
  72. package/src/lib/search.ts +59 -0
  73. package/src/lib/useApiData.ts +95 -0
  74. package/src/lib/utils.ts +6 -0
  75. package/src/router/index.ts +81 -0
  76. package/src/styles/build-input.css +11 -0
  77. package/src/styles/tailwind.css +11 -6
  78. package/src/styles/theme.css +182 -181
  79. package/src/views/CollectionList.tsx +270 -0
  80. package/src/views/Dashboard.tsx +300 -0
  81. package/src/views/DocumentEdit.tsx +377 -0
  82. package/src/views/FormEditor.tsx +533 -0
  83. package/src/views/FormSubmissions.tsx +316 -0
  84. package/src/views/Forms.tsx +106 -0
  85. package/src/views/Login.tsx +322 -0
  86. package/src/views/MediaBrowser.tsx +774 -0
  87. package/src/views/PageEditor.tsx +192 -0
  88. package/src/views/Pages.tsx +354 -0
  89. package/src/views/PostEditor.tsx +251 -0
  90. package/src/views/Posts.tsx +243 -0
  91. package/src/views/Redirects.tsx +293 -0
  92. package/src/views/SEO.tsx +458 -0
  93. package/src/views/Settings.tsx +811 -0
  94. package/src/views/SetupWizard.tsx +207 -0
  95. package/src/views/Users.tsx +282 -0
@@ -0,0 +1,192 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { ArrowLeft, Eye, Loader2, AlertTriangle } from 'lucide-react';
5
+ import { toast } from 'sonner';
6
+ import { SEOPanel, type SEOData } from '../components/SEOPanel.js';
7
+ import { useApiData } from '../lib/useApiData.js';
8
+ import { cmsApi } from '../lib/api.js';
9
+
10
+ export interface PageEditorProps {
11
+ id?: string;
12
+ onNavigate?: (path: string) => void;
13
+ }
14
+
15
+ export function PageEditor({ id, onNavigate }: PageEditorProps) {
16
+ const isNew = !id;
17
+ const { data, loading, error } = useApiData<any>(
18
+ isNew ? '' : `/collections/pages/${id}`
19
+ );
20
+
21
+ const [title, setTitle] = useState('');
22
+ const [slug, setSlug] = useState('');
23
+ const [status, setStatus] = useState<'draft' | 'published'>('draft');
24
+ const [saving, setSaving] = useState(false);
25
+ const [initialized, setInitialized] = useState(isNew);
26
+ const [seoData, setSeoData] = useState<SEOData>({});
27
+
28
+ useEffect(() => {
29
+ if (data && !initialized) {
30
+ setTitle(data.title ?? '');
31
+ setSlug(data.slug ?? '');
32
+ setStatus(data.status === 'PUBLISHED' ? 'published' : 'draft');
33
+ setInitialized(true);
34
+ }
35
+ }, [data, initialized]);
36
+
37
+ const savePage = async () => {
38
+ setSaving(true);
39
+ const body = JSON.stringify({
40
+ title,
41
+ slug,
42
+ status: status === 'published' ? 'PUBLISHED' : 'DRAFT',
43
+ });
44
+
45
+ const res = isNew
46
+ ? await cmsApi('/collections/pages', { method: 'POST', body })
47
+ : await cmsApi(`/collections/pages/${id}`, { method: 'PUT', body });
48
+
49
+ setSaving(false);
50
+ if (res.error) {
51
+ toast.error(res.error);
52
+ } else {
53
+ toast.success('Page saved successfully!');
54
+ if (isNew && (res.data as any)?.id) {
55
+ onNavigate?.(`/pages/${(res.data as any).id}`);
56
+ }
57
+ }
58
+ };
59
+
60
+ const publishPage = async () => {
61
+ setSaving(true);
62
+ const body = JSON.stringify({
63
+ title,
64
+ slug,
65
+ status: 'PUBLISHED',
66
+ });
67
+
68
+ const endpoint = isNew ? '/collections/pages' : `/collections/pages/${id}`;
69
+ const method = isNew ? 'POST' : 'PUT';
70
+ const res = await cmsApi(endpoint, { method, body });
71
+
72
+ setSaving(false);
73
+ if (res.error) {
74
+ toast.error(res.error);
75
+ } else {
76
+ setStatus('published');
77
+ toast.success('Page published!');
78
+ if (isNew && (res.data as any)?.id) {
79
+ onNavigate?.(`/pages/${(res.data as any).id}`);
80
+ }
81
+ }
82
+ };
83
+
84
+ if (!isNew && loading) {
85
+ return (
86
+ <div className="h-full flex items-center justify-center">
87
+ <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
88
+ </div>
89
+ );
90
+ }
91
+
92
+ return (
93
+ <div className="h-full flex flex-col bg-white">
94
+ {error && (
95
+ <div className="mx-4 mt-3 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
96
+ <AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
97
+ <span className="text-sm text-red-800 flex-1">{error}</span>
98
+ </div>
99
+ )}
100
+
101
+ <div className="border-b border-gray-200 px-4 py-3">
102
+ <div className="flex items-center justify-between">
103
+ <button
104
+ onClick={() => onNavigate?.('/pages')}
105
+ className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
106
+ >
107
+ <ArrowLeft className="w-4 h-4" />
108
+ Back to Pages
109
+ </button>
110
+ <div className="flex items-center gap-3">
111
+ <button className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg flex items-center gap-2 hover:bg-gray-50 transition-colors">
112
+ <Eye className="w-4 h-4" />
113
+ Preview
114
+ </button>
115
+ </div>
116
+ </div>
117
+ </div>
118
+
119
+ <div className="flex-1 overflow-hidden flex">
120
+ <div className="flex-1 overflow-y-auto">
121
+ <div className="max-w-4xl mx-auto p-3 pr-6 sm:p-4 sm:pr-8">
122
+ <input
123
+ type="text"
124
+ value={title}
125
+ onChange={(e) => setTitle(e.target.value)}
126
+ placeholder="Page title"
127
+ className="w-full text-2xl sm:text-3xl font-bold mb-4 px-0 border-none focus:outline-none focus:ring-0 placeholder:text-gray-300"
128
+ />
129
+
130
+ <div className="mb-4">
131
+ <label className="block text-xs font-medium text-gray-600 mb-1">URL Slug</label>
132
+ <input
133
+ type="text"
134
+ value={slug}
135
+ onChange={(e) => setSlug(e.target.value)}
136
+ placeholder="url-slug"
137
+ className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
138
+ />
139
+ </div>
140
+
141
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
142
+ <p className="text-gray-500 mb-2">No blocks added yet</p>
143
+ <p className="text-sm text-gray-400">Click "Add Block" to start building your page</p>
144
+ <button className="mt-4 px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
145
+ Add Block
146
+ </button>
147
+ </div>
148
+ </div>
149
+ </div>
150
+
151
+ <div className="w-[30%] border-l border-gray-200 overflow-y-auto bg-gray-50">
152
+ <div className="p-4 space-y-4">
153
+ <div className="bg-white rounded-lg border border-gray-200 p-4">
154
+ <h3 className="font-semibold text-gray-900 mb-3 text-sm">Publish</h3>
155
+ <div className="space-y-3">
156
+ <div>
157
+ <label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
158
+ <select
159
+ value={status}
160
+ onChange={(e) => setStatus(e.target.value as 'draft' | 'published')}
161
+ className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
162
+ >
163
+ <option value="draft">Draft</option>
164
+ <option value="published">Published</option>
165
+ </select>
166
+ </div>
167
+ <button
168
+ onClick={savePage}
169
+ disabled={saving}
170
+ className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium disabled:opacity-50"
171
+ >
172
+ {saving ? 'Saving...' : 'Save Page'}
173
+ </button>
174
+ {status === 'draft' && (
175
+ <button
176
+ onClick={publishPage}
177
+ disabled={saving}
178
+ className="w-full py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium disabled:opacity-50"
179
+ >
180
+ {saving ? 'Publishing...' : 'Publish'}
181
+ </button>
182
+ )}
183
+ </div>
184
+ </div>
185
+
186
+ <SEOPanel title={title} slug={slug} seoData={seoData} onChange={setSeoData} />
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ );
192
+ }
@@ -0,0 +1,354 @@
1
+ 'use client';
2
+
3
+ import { Plus, Search, Trash2, SlidersHorizontal, Pencil, ArrowUpDown, ArrowUp, ArrowDown, Loader2, AlertTriangle, GripVertical, FolderInput } from 'lucide-react';
4
+ import { useState, useMemo, useCallback } from 'react';
5
+ import { toast } from 'sonner';
6
+ import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js';
7
+ import { useApiData } from '../lib/useApiData.js';
8
+ import { cmsApi } from '../lib/api.js';
9
+ import { FolderTree, type FolderSelection } from '../components/FolderTree.js';
10
+
11
+ type PageSortKey = 'title' | 'author' | 'template' | 'status' | 'date';
12
+
13
+ export interface PagesProps {
14
+ onNavigate?: (path: string) => void;
15
+ }
16
+
17
+ function buildApiUrl(folderSel: FolderSelection): string {
18
+ const base = '/collections/pages?pageSize=100';
19
+ if (folderSel.type === 'smart') {
20
+ if (folderSel.smart === 'recent') return `${base}&sort=updatedAt&order=desc&pageSize=20`;
21
+ if (folderSel.smart === 'uncategorized') return `${base}&folderId=none`;
22
+ return base;
23
+ }
24
+ return `${base}&folderId=${folderSel.folderId}`;
25
+ }
26
+
27
+ export function Pages({ onNavigate }: PagesProps) {
28
+ const [folderSel, setFolderSel] = useState<FolderSelection>({ type: 'smart', smart: 'all' });
29
+ const [sidebarOpen, setSidebarOpen] = useState(true);
30
+
31
+ const apiUrl = useMemo(() => buildApiUrl(folderSel), [folderSel]);
32
+ const { data, loading, error, refetch } = useApiData<{ docs: any[]; total: number }>(apiUrl);
33
+
34
+ const allData = useApiData<{ docs: any[]; total: number }>('/collections/pages?pageSize=1');
35
+ const uncatData = useApiData<{ docs: any[]; total: number }>('/collections/pages?pageSize=1&folderId=none');
36
+
37
+ const [searchQuery, setSearchQuery] = useState('');
38
+ const [filterStatus, setFilterStatus] = useState<string>('all');
39
+ const [filterTemplate, setFilterTemplate] = useState<string>('all');
40
+ const [selectedPages, setSelectedPages] = useState<number[]>([]);
41
+ const [sortConfig, setSortConfig] = useState<SortConfig<PageSortKey> | null>(null);
42
+
43
+ const pages = data?.docs ?? [];
44
+
45
+ const filteredAndSorted = useMemo(() => {
46
+ let results = pages.filter((page: any) => {
47
+ const matchesSearch = (page.title ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
48
+ (page.author ?? '').toLowerCase().includes(searchQuery.toLowerCase());
49
+ const matchesStatus = filterStatus === 'all' || (page.status ?? '').toLowerCase() === filterStatus.toLowerCase();
50
+ const matchesTemplate = filterTemplate === 'all' || (page.template ?? '').toLowerCase() === filterTemplate.toLowerCase();
51
+ return matchesSearch && matchesStatus && matchesTemplate;
52
+ });
53
+
54
+ if (searchQuery.trim()) {
55
+ results = sortByRelevance(results, searchQuery, (p: any) => [p.title, p.author ?? '', p.template ?? '']);
56
+ } else if (sortConfig) {
57
+ results = [...results].sort((a: any, b: any) => {
58
+ const aVal = a[sortConfig.key] ?? '';
59
+ const bVal = b[sortConfig.key] ?? '';
60
+ const cmp = String(aVal).localeCompare(String(bVal));
61
+ return sortConfig.direction === 'asc' ? cmp : -cmp;
62
+ });
63
+ }
64
+ return results;
65
+ }, [pages, searchQuery, filterStatus, filterTemplate, sortConfig]);
66
+
67
+ const handleSelectAll = (checked: boolean) => {
68
+ setSelectedPages(checked ? filteredAndSorted.map((p: any) => p.id) : []);
69
+ };
70
+
71
+ const handleSelectPage = (id: number) => {
72
+ setSelectedPages(prev => prev.includes(id) ? prev.filter(pid => pid !== id) : [...prev, id]);
73
+ };
74
+
75
+ const handleBulkDelete = async () => {
76
+ for (const id of selectedPages) {
77
+ await cmsApi(`/collections/pages/${id}`, { method: 'DELETE' });
78
+ }
79
+ toast.success(`${selectedPages.length} pages deleted`);
80
+ setSelectedPages([]);
81
+ refetch();
82
+ };
83
+
84
+ const handleBulkPublish = async () => {
85
+ for (const id of selectedPages) {
86
+ await cmsApi(`/collections/pages/${id}`, {
87
+ method: 'PUT',
88
+ body: JSON.stringify({ status: 'PUBLISHED' }),
89
+ });
90
+ }
91
+ toast.success(`${selectedPages.length} pages published`);
92
+ setSelectedPages([]);
93
+ refetch();
94
+ };
95
+
96
+ const handleBulkUnpublish = async () => {
97
+ for (const id of selectedPages) {
98
+ await cmsApi(`/collections/pages/${id}`, {
99
+ method: 'PUT',
100
+ body: JSON.stringify({ status: 'DRAFT' }),
101
+ });
102
+ }
103
+ toast.success(`${selectedPages.length} pages unpublished`);
104
+ setSelectedPages([]);
105
+ refetch();
106
+ };
107
+
108
+ const handleDelete = async (id: number) => {
109
+ await cmsApi(`/collections/pages/${id}`, { method: 'DELETE' });
110
+ toast.success('Page deleted');
111
+ setSelectedPages(prev => prev.filter(pid => pid !== id));
112
+ refetch();
113
+ };
114
+
115
+ const handleDropItem = useCallback(async (itemId: string, folderId: string | null) => {
116
+ const res = await cmsApi(`/documents/${itemId}/folder`, {
117
+ method: 'PUT',
118
+ body: JSON.stringify({ folderId }),
119
+ });
120
+ if (res.error) {
121
+ toast.error(res.error);
122
+ } else {
123
+ toast.success(folderId ? 'Moved to folder' : 'Removed from folder');
124
+ refetch();
125
+ }
126
+ }, [refetch]);
127
+
128
+ const handleDragStart = (e: React.DragEvent, id: string) => {
129
+ e.dataTransfer.setData('text/actuate-item-id', id);
130
+ e.dataTransfer.effectAllowed = 'move';
131
+ };
132
+
133
+ function SortHeader({ label, sortKey }: { label: string; sortKey: PageSortKey }) {
134
+ const active = sortConfig?.key === sortKey;
135
+ return (
136
+ <button
137
+ type="button"
138
+ onClick={() => setSortConfig(toggleSort(sortConfig, sortKey))}
139
+ className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
140
+ >
141
+ {label}
142
+ {active ? (
143
+ sortConfig!.direction === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />
144
+ ) : (
145
+ <ArrowUpDown className="w-3 h-3 text-gray-400" />
146
+ )}
147
+ </button>
148
+ );
149
+ }
150
+
151
+ if (loading) {
152
+ return (
153
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
154
+ <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
155
+ </div>
156
+ );
157
+ }
158
+
159
+ return (
160
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 h-full flex flex-col">
161
+ {error && (
162
+ <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
163
+ <AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
164
+ <span className="text-sm text-red-800 flex-1">{error}</span>
165
+ <button onClick={refetch} className="px-3 py-1 text-sm text-red-700 border border-red-300 rounded-lg hover:bg-red-100 transition-colors">Retry</button>
166
+ </div>
167
+ )}
168
+
169
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 gap-3">
170
+ <div className="flex items-center gap-3">
171
+ <button
172
+ type="button"
173
+ onClick={() => setSidebarOpen(prev => !prev)}
174
+ className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors"
175
+ title={sidebarOpen ? 'Hide folders' : 'Show folders'}
176
+ >
177
+ <FolderInput className="w-5 h-5 text-gray-600" />
178
+ </button>
179
+ <div>
180
+ <h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">Pages</h1>
181
+ <p className="text-sm text-gray-600">{filteredAndSorted.length} total pages</p>
182
+ </div>
183
+ </div>
184
+ <button
185
+ onClick={() => onNavigate?.('/pages/new')}
186
+ className="flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
187
+ >
188
+ <Plus className="w-4 h-4" />
189
+ <span className="hidden sm:inline">New Page</span>
190
+ <span className="sm:hidden">New</span>
191
+ </button>
192
+ </div>
193
+
194
+ <div className="flex gap-4 flex-1 min-h-0 overflow-hidden">
195
+ {sidebarOpen && (
196
+ <div className="w-56 shrink-0 bg-white rounded-lg border border-gray-200 overflow-hidden flex flex-col">
197
+ <FolderTree
198
+ scope="pages"
199
+ selected={folderSel}
200
+ onSelect={(sel) => { setFolderSel(sel); setSelectedPages([]); }}
201
+ totalCount={allData.data?.total}
202
+ uncategorizedCount={uncatData.data?.total}
203
+ onDropItem={handleDropItem}
204
+ />
205
+ </div>
206
+ )}
207
+
208
+ <div className="flex-1 flex flex-col min-w-0">
209
+ <div className="bg-white rounded-lg border border-gray-200 mb-4">
210
+ <div className="p-3 flex flex-col gap-3">
211
+ <div className="relative">
212
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
213
+ <input type="text" placeholder="Search pages..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
214
+ </div>
215
+ <div className="flex flex-col sm:flex-row gap-2">
216
+ <select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
217
+ <option value="all">All Status</option>
218
+ <option value="published">Published</option>
219
+ <option value="draft">Draft</option>
220
+ </select>
221
+ <select value={filterTemplate} onChange={(e) => setFilterTemplate(e.target.value)} className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
222
+ <option value="all">All Templates</option>
223
+ <option value="landing">Landing</option>
224
+ <option value="standard">Standard</option>
225
+ <option value="marketing">Marketing</option>
226
+ <option value="blog">Blog</option>
227
+ </select>
228
+ <button type="button" className="flex items-center justify-center gap-2 px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
229
+ <SlidersHorizontal className="w-4 h-4" />
230
+ <span className="hidden sm:inline">More Filters</span>
231
+ </button>
232
+ </div>
233
+ </div>
234
+ </div>
235
+
236
+ {selectedPages.length > 0 && (
237
+ <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
238
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
239
+ <span className="text-sm text-blue-900">{selectedPages.length} page{selectedPages.length !== 1 ? 's' : ''} selected</span>
240
+ <div className="flex items-center gap-2">
241
+ <button type="button" onClick={handleBulkPublish} className="flex-1 sm:flex-none px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">Publish</button>
242
+ <button type="button" onClick={handleBulkUnpublish} className="flex-1 sm:flex-none px-3 py-1.5 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors">Unpublish</button>
243
+ <button type="button" onClick={handleBulkDelete} className="flex-1 sm:flex-none px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">Delete</button>
244
+ <button type="button" onClick={() => setSelectedPages([])} className="flex-1 sm:flex-none px-3 py-1.5 text-sm border border-gray-300 bg-white rounded-lg hover:bg-gray-50 transition-colors">Cancel</button>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ )}
249
+
250
+ {filteredAndSorted.length === 0 && !error ? (
251
+ <div className="bg-white rounded-lg border border-gray-200 p-8 text-center flex-1 flex flex-col items-center justify-center">
252
+ <p className="text-sm text-gray-500 mb-2">
253
+ {folderSel.type === 'smart' && folderSel.smart === 'uncategorized'
254
+ ? 'No uncategorized pages'
255
+ : folderSel.type === 'folder'
256
+ ? 'No pages in this folder'
257
+ : 'No pages yet'}
258
+ </p>
259
+ <button onClick={() => onNavigate?.('/pages/new')} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Create your first page</button>
260
+ </div>
261
+ ) : (
262
+ <>
263
+ <div className="hidden md:block bg-white rounded-lg border border-gray-200 flex-1 overflow-hidden">
264
+ <div className="overflow-x-auto h-full">
265
+ <table className="w-full">
266
+ <thead className="bg-gray-50 border-b border-gray-200 sticky top-0">
267
+ <tr>
268
+ <th className="w-8 px-3 py-2 text-left">
269
+ <input type="checkbox" checked={selectedPages.length === filteredAndSorted.length && filteredAndSorted.length > 0} onChange={(e) => handleSelectAll(e.target.checked)} className="rounded border-gray-300" />
270
+ </th>
271
+ <th className="w-6 px-1 py-2"></th>
272
+ <th className="px-3 py-2 text-left"><SortHeader label="Title" sortKey="title" /></th>
273
+ <th className="px-3 py-2 text-left"><SortHeader label="Author" sortKey="author" /></th>
274
+ <th className="px-3 py-2 text-left"><SortHeader label="Template" sortKey="template" /></th>
275
+ <th className="px-3 py-2 text-left"><SortHeader label="Status" sortKey="status" /></th>
276
+ <th className="px-3 py-2 text-left"><SortHeader label="Date" sortKey="date" /></th>
277
+ <th className="px-3 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
278
+ </tr>
279
+ </thead>
280
+ <tbody className="divide-y divide-gray-200">
281
+ {filteredAndSorted.map((page: any) => (
282
+ <tr
283
+ key={page.id}
284
+ className="hover:bg-gray-50 transition-colors"
285
+ draggable
286
+ onDragStart={(e) => handleDragStart(e, page.id)}
287
+ >
288
+ <td className="px-3 py-2">
289
+ <input type="checkbox" checked={selectedPages.includes(page.id)} onChange={() => handleSelectPage(page.id)} className="rounded border-gray-300" />
290
+ </td>
291
+ <td className="px-1 py-2 cursor-grab">
292
+ <GripVertical className="w-4 h-4 text-gray-300" />
293
+ </td>
294
+ <td className="px-3 py-2">
295
+ <button type="button" onClick={() => onNavigate?.(`/pages/${page.id}`)} className="font-medium text-gray-900 hover:text-blue-600 text-sm text-left">{page.title}</button>
296
+ </td>
297
+ <td className="px-3 py-2 text-sm text-gray-600">{page.author}</td>
298
+ <td className="px-3 py-2 text-sm text-gray-600">{page.template}</td>
299
+ <td className="px-3 py-2">
300
+ <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${page.status === 'Published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>{page.status}</span>
301
+ </td>
302
+ <td className="px-3 py-2 text-sm text-gray-600">{page.date}</td>
303
+ <td className="px-3 py-2">
304
+ <div className="flex items-center gap-2">
305
+ <button type="button" onClick={() => onNavigate?.(`/pages/${page.id}`)} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
306
+ <Pencil className="w-4 h-4 text-gray-600" />
307
+ </button>
308
+ <button type="button" onClick={() => handleDelete(page.id)} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Delete">
309
+ <Trash2 className="w-4 h-4 text-red-600" />
310
+ </button>
311
+ </div>
312
+ </td>
313
+ </tr>
314
+ ))}
315
+ </tbody>
316
+ </table>
317
+ </div>
318
+ </div>
319
+
320
+ <div className="md:hidden bg-white rounded-lg border border-gray-200 flex-1 overflow-auto">
321
+ <div className="divide-y divide-gray-200">
322
+ {filteredAndSorted.map((page: any) => (
323
+ <div
324
+ key={page.id}
325
+ className="p-3"
326
+ draggable
327
+ onDragStart={(e) => handleDragStart(e, page.id)}
328
+ >
329
+ <div className="flex items-start gap-3">
330
+ <input type="checkbox" checked={selectedPages.includes(page.id)} onChange={() => handleSelectPage(page.id)} className="rounded border-gray-300 mt-1" />
331
+ <div className="flex-1 min-w-0">
332
+ <button type="button" onClick={() => onNavigate?.(`/pages/${page.id}`)} className="font-medium text-sm text-gray-900 hover:text-blue-600 block mb-1 text-left">{page.title}</button>
333
+ <div className="flex flex-wrap items-center gap-2 text-xs text-gray-600 mb-2">
334
+ <span>{page.author}</span>
335
+ <span>•</span>
336
+ <span>{page.date}</span>
337
+ <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${page.status === 'Published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>{page.status}</span>
338
+ </div>
339
+ </div>
340
+ <button type="button" onClick={() => handleDelete(page.id)} className="p-1.5 hover:bg-gray-100 rounded transition-colors">
341
+ <Trash2 className="w-4 h-4 text-red-600" />
342
+ </button>
343
+ </div>
344
+ </div>
345
+ ))}
346
+ </div>
347
+ </div>
348
+ </>
349
+ )}
350
+ </div>
351
+ </div>
352
+ </div>
353
+ );
354
+ }