@actuate-media/cms-admin 0.1.4 → 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.
- package/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +16 -10
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +2 -0
- package/dist/components/TipTapEditor.js +78 -78
- package/dist/lib/useApiData.d.ts +8 -1
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +39 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +8 -3
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +10 -5
- package/src/AdminRoot.tsx +312 -0
- package/src/__tests__/lib/search.test.ts +138 -0
- package/src/__tests__/lib/utils.test.ts +19 -0
- package/src/__tests__/router/match-route.test.ts +47 -0
- package/src/__tests__/router/strip-base.test.ts +30 -0
- package/src/components/Breadcrumbs.tsx +92 -0
- package/src/components/CommandPalette.tsx +384 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/FocalPointPicker.tsx +54 -0
- package/src/components/FolderTree.tsx +427 -0
- package/src/components/LivePreview.tsx +136 -0
- package/src/components/LocaleProvider.tsx +51 -0
- package/src/components/LocaleSwitcher.tsx +51 -0
- package/src/components/MediaPickerModal.tsx +183 -0
- package/src/components/PresenceIndicator.tsx +71 -0
- package/src/components/SEOPanel.tsx +767 -0
- package/src/components/ThemeProvider.tsx +98 -0
- package/src/components/TipTapEditor.tsx +469 -0
- package/src/components/VersionHistory.tsx +167 -0
- package/src/components/ui/Avatar.tsx +42 -0
- package/src/components/ui/Badge.tsx +25 -0
- package/src/components/ui/Button.tsx +52 -0
- package/src/components/ui/CommandPalette.tsx +119 -0
- package/src/components/ui/ConfirmDialog.tsx +52 -0
- package/src/components/ui/DataTable.tsx +194 -0
- package/src/components/ui/EmptyState.tsx +29 -0
- package/src/components/ui/Modal.tsx +48 -0
- package/src/components/ui/Pagination.tsx +79 -0
- package/src/components/ui/SearchInput.tsx +44 -0
- package/src/components/ui/Skeleton.tsx +48 -0
- package/src/components/ui/Toast.tsx +66 -0
- package/src/components/ui/index.ts +24 -0
- package/src/fields/ArrayField.tsx +92 -0
- package/src/fields/BlockBuilderField.tsx +421 -0
- package/src/fields/DateField.tsx +41 -0
- package/src/fields/FieldRenderer.tsx +84 -0
- package/src/fields/GroupField.tsx +41 -0
- package/src/fields/MediaField.tsx +48 -0
- package/src/fields/NavBuilderField.tsx +78 -0
- package/src/fields/NumberField.tsx +45 -0
- package/src/fields/RelationshipField.tsx +245 -0
- package/src/fields/RichTextField.tsx +26 -0
- package/src/fields/SelectField.tsx +117 -0
- package/src/fields/SlugField.tsx +65 -0
- package/src/fields/TextField.tsx +48 -0
- package/src/fields/ToggleField.tsx +36 -0
- package/src/fields/block-types.ts +95 -0
- package/src/fields/index.ts +17 -0
- package/src/hooks/useContentLock.ts +52 -0
- package/src/hooks/useDebounce.ts +14 -0
- package/src/hooks/useKeyboardShortcuts.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout/Header.tsx +135 -0
- package/src/layout/Layout.tsx +77 -0
- package/src/layout/Sidebar.tsx +216 -0
- package/src/lib/api.ts +67 -0
- package/src/lib/search.ts +59 -0
- package/src/lib/useApiData.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/router/index.ts +81 -0
- package/src/styles/build-input.css +11 -0
- package/src/styles/tailwind.css +11 -6
- package/src/styles/theme.css +182 -181
- package/src/views/CollectionList.tsx +270 -0
- package/src/views/Dashboard.tsx +207 -0
- package/src/views/DocumentEdit.tsx +377 -0
- package/src/views/FormEditor.tsx +533 -0
- package/src/views/FormSubmissions.tsx +316 -0
- package/src/views/Forms.tsx +106 -0
- package/src/views/Login.tsx +322 -0
- package/src/views/MediaBrowser.tsx +774 -0
- package/src/views/PageEditor.tsx +192 -0
- package/src/views/Pages.tsx +354 -0
- package/src/views/PostEditor.tsx +251 -0
- package/src/views/Posts.tsx +243 -0
- package/src/views/Redirects.tsx +293 -0
- package/src/views/SEO.tsx +458 -0
- package/src/views/Settings.tsx +811 -0
- package/src/views/SetupWizard.tsx +207 -0
- package/src/views/Users.tsx +282 -0
|
@@ -0,0 +1,251 @@
|
|
|
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 { TipTapEditor } from '../components/TipTapEditor.js';
|
|
7
|
+
import { SEOPanel, type SEOData } from '../components/SEOPanel.js';
|
|
8
|
+
import { useApiData } from '../lib/useApiData.js';
|
|
9
|
+
import { cmsApi } from '../lib/api.js';
|
|
10
|
+
|
|
11
|
+
export interface PostEditorProps {
|
|
12
|
+
id?: string;
|
|
13
|
+
onNavigate?: (path: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function PostEditor({ id, onNavigate }: PostEditorProps) {
|
|
17
|
+
const isNew = !id;
|
|
18
|
+
const { data, loading, error } = useApiData<any>(
|
|
19
|
+
isNew ? '' : `/collections/posts/${id}`
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const [title, setTitle] = useState('');
|
|
23
|
+
const [content, setContent] = useState('');
|
|
24
|
+
const [status, setStatus] = useState<'draft' | 'published'>('draft');
|
|
25
|
+
const [slug, setSlug] = useState('');
|
|
26
|
+
const [saving, setSaving] = useState(false);
|
|
27
|
+
const [initialized, setInitialized] = useState(isNew);
|
|
28
|
+
const [seoData, setSeoData] = useState<SEOData>({});
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (data && !initialized) {
|
|
32
|
+
setTitle(data.title ?? '');
|
|
33
|
+
setContent(data.content ?? data.data?.content ?? '');
|
|
34
|
+
setStatus(data.status === 'PUBLISHED' ? 'published' : 'draft');
|
|
35
|
+
setSlug(data.slug ?? '');
|
|
36
|
+
setInitialized(true);
|
|
37
|
+
}
|
|
38
|
+
}, [data, initialized]);
|
|
39
|
+
|
|
40
|
+
const savePost = async () => {
|
|
41
|
+
setSaving(true);
|
|
42
|
+
const body = JSON.stringify({
|
|
43
|
+
title,
|
|
44
|
+
content,
|
|
45
|
+
slug,
|
|
46
|
+
status: status === 'published' ? 'PUBLISHED' : 'DRAFT',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const res = isNew
|
|
50
|
+
? await cmsApi('/collections/posts', { method: 'POST', body })
|
|
51
|
+
: await cmsApi(`/collections/posts/${id}`, { method: 'PUT', body });
|
|
52
|
+
|
|
53
|
+
setSaving(false);
|
|
54
|
+
if (res.error) {
|
|
55
|
+
toast.error(res.error);
|
|
56
|
+
} else {
|
|
57
|
+
toast.success('Post saved successfully!');
|
|
58
|
+
if (isNew && (res.data as any)?.id) {
|
|
59
|
+
onNavigate?.(`/posts/${(res.data as any).id}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const publishPost = async () => {
|
|
65
|
+
setSaving(true);
|
|
66
|
+
const body = JSON.stringify({
|
|
67
|
+
title,
|
|
68
|
+
content,
|
|
69
|
+
slug,
|
|
70
|
+
status: 'PUBLISHED',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const endpoint = isNew ? '/collections/posts' : `/collections/posts/${id}`;
|
|
74
|
+
const method = isNew ? 'POST' : 'PUT';
|
|
75
|
+
const res = await cmsApi(endpoint, { method, body });
|
|
76
|
+
|
|
77
|
+
setSaving(false);
|
|
78
|
+
if (res.error) {
|
|
79
|
+
toast.error(res.error);
|
|
80
|
+
} else {
|
|
81
|
+
setStatus('published');
|
|
82
|
+
toast.success('Post published!');
|
|
83
|
+
if (isNew && (res.data as any)?.id) {
|
|
84
|
+
onNavigate?.(`/posts/${(res.data as any).id}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (!isNew && loading) {
|
|
90
|
+
return (
|
|
91
|
+
<div className="h-full flex items-center justify-center">
|
|
92
|
+
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="h-full flex flex-col bg-white">
|
|
99
|
+
{error && (
|
|
100
|
+
<div className="mx-4 mt-3 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
|
101
|
+
<AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
|
|
102
|
+
<span className="text-sm text-red-800 flex-1">{error}</span>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<div className="border-b border-gray-200 px-3 sm:px-4 py-3">
|
|
107
|
+
<div className="flex items-center justify-between">
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => onNavigate?.('/posts')}
|
|
110
|
+
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
|
111
|
+
>
|
|
112
|
+
<ArrowLeft className="w-4 h-4" />
|
|
113
|
+
<span className="hidden sm:inline">Back to Posts</span>
|
|
114
|
+
<span className="sm:hidden">Back</span>
|
|
115
|
+
</button>
|
|
116
|
+
<div className="flex items-center gap-2">
|
|
117
|
+
<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">
|
|
118
|
+
<Eye className="w-4 h-4" />
|
|
119
|
+
<span className="hidden sm:inline">Preview</span>
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
|
|
126
|
+
<div className="flex-1 overflow-y-auto">
|
|
127
|
+
<div className="max-w-4xl mx-auto p-3 pr-6 sm:p-4 sm:pr-8">
|
|
128
|
+
<input
|
|
129
|
+
type="text"
|
|
130
|
+
value={title}
|
|
131
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
132
|
+
placeholder="Post title"
|
|
133
|
+
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"
|
|
134
|
+
/>
|
|
135
|
+
|
|
136
|
+
<div className="mb-4">
|
|
137
|
+
<label className="block text-xs font-medium text-gray-600 mb-1">URL Slug</label>
|
|
138
|
+
<input
|
|
139
|
+
type="text"
|
|
140
|
+
value={slug}
|
|
141
|
+
onChange={(e) => setSlug(e.target.value)}
|
|
142
|
+
placeholder="url-slug"
|
|
143
|
+
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"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div className="mb-4">
|
|
148
|
+
<TipTapEditor content={content} onChange={setContent} />
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="hidden lg:block w-[30%] border-l border-gray-200 overflow-y-auto bg-gray-50">
|
|
154
|
+
<div className="p-4 space-y-4">
|
|
155
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
156
|
+
<h3 className="font-semibold text-gray-900 mb-3 text-sm">Publish</h3>
|
|
157
|
+
<div className="space-y-3">
|
|
158
|
+
<div>
|
|
159
|
+
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
|
|
160
|
+
<select
|
|
161
|
+
value={status}
|
|
162
|
+
onChange={(e) => setStatus(e.target.value as 'draft' | 'published')}
|
|
163
|
+
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"
|
|
164
|
+
>
|
|
165
|
+
<option value="draft">Draft</option>
|
|
166
|
+
<option value="published">Published</option>
|
|
167
|
+
</select>
|
|
168
|
+
</div>
|
|
169
|
+
<div>
|
|
170
|
+
<label className="block text-xs font-medium text-gray-700 mb-1">Publish Date</label>
|
|
171
|
+
<input
|
|
172
|
+
type="datetime-local"
|
|
173
|
+
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"
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
<button
|
|
177
|
+
onClick={savePost}
|
|
178
|
+
disabled={saving}
|
|
179
|
+
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"
|
|
180
|
+
>
|
|
181
|
+
{saving ? 'Saving...' : 'Save Post'}
|
|
182
|
+
</button>
|
|
183
|
+
{status === 'draft' && (
|
|
184
|
+
<button
|
|
185
|
+
onClick={publishPost}
|
|
186
|
+
disabled={saving}
|
|
187
|
+
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"
|
|
188
|
+
>
|
|
189
|
+
{saving ? 'Publishing...' : 'Publish'}
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
196
|
+
<h3 className="font-semibold text-gray-900 mb-3 text-sm">Categories & Tags</h3>
|
|
197
|
+
<div className="space-y-3">
|
|
198
|
+
<div>
|
|
199
|
+
<label className="block text-xs font-medium text-gray-700 mb-1">Category</label>
|
|
200
|
+
<select 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">
|
|
201
|
+
<option>Technology</option>
|
|
202
|
+
<option>Design</option>
|
|
203
|
+
<option>Business</option>
|
|
204
|
+
</select>
|
|
205
|
+
</div>
|
|
206
|
+
<div>
|
|
207
|
+
<label className="block text-xs font-medium text-gray-700 mb-1">Tags</label>
|
|
208
|
+
<input
|
|
209
|
+
type="text"
|
|
210
|
+
placeholder="Add tags..."
|
|
211
|
+
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"
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
218
|
+
<h3 className="font-semibold text-gray-900 mb-3 text-sm">Featured Image</h3>
|
|
219
|
+
<button className="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-gray-400 transition-colors text-sm text-gray-600">
|
|
220
|
+
Upload Image
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<SEOPanel title={title} slug={slug} content={content} seoData={seoData} onChange={setSeoData} />
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div className="lg:hidden border-t border-gray-200 p-3 bg-white">
|
|
229
|
+
<div className="flex gap-2">
|
|
230
|
+
<button
|
|
231
|
+
onClick={savePost}
|
|
232
|
+
disabled={saving}
|
|
233
|
+
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium disabled:opacity-50"
|
|
234
|
+
>
|
|
235
|
+
{saving ? 'Saving...' : 'Save Post'}
|
|
236
|
+
</button>
|
|
237
|
+
{status === 'draft' && (
|
|
238
|
+
<button
|
|
239
|
+
onClick={publishPost}
|
|
240
|
+
disabled={saving}
|
|
241
|
+
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium disabled:opacity-50"
|
|
242
|
+
>
|
|
243
|
+
Publish
|
|
244
|
+
</button>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Plus, Search, Trash2, SlidersHorizontal, Pencil, ArrowUpDown, ArrowUp, ArrowDown, Loader2, AlertTriangle } from 'lucide-react';
|
|
4
|
+
import { useState, useMemo } 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
|
+
|
|
10
|
+
type PostSortKey = 'title' | 'author' | 'category' | 'status' | 'date';
|
|
11
|
+
|
|
12
|
+
export interface PostsProps {
|
|
13
|
+
onNavigate?: (path: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Posts({ onNavigate }: PostsProps) {
|
|
17
|
+
const { data, loading, error, refetch } = useApiData<{ docs: any[]; total: number }>('/collections/posts?pageSize=100');
|
|
18
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
19
|
+
const [filterStatus, setFilterStatus] = useState<string>('all');
|
|
20
|
+
const [filterCategory, setFilterCategory] = useState<string>('all');
|
|
21
|
+
const [selectedPosts, setSelectedPosts] = useState<number[]>([]);
|
|
22
|
+
const [sortConfig, setSortConfig] = useState<SortConfig<PostSortKey> | null>(null);
|
|
23
|
+
|
|
24
|
+
const posts = data?.docs ?? [];
|
|
25
|
+
|
|
26
|
+
const filteredAndSorted = useMemo(() => {
|
|
27
|
+
let results = posts.filter((post: any) => {
|
|
28
|
+
const matchesSearch = post.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
29
|
+
(post.author ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
|
30
|
+
const matchesStatus = filterStatus === 'all' || (post.status ?? '').toLowerCase() === filterStatus.toLowerCase();
|
|
31
|
+
const matchesCategory = filterCategory === 'all' || (post.category ?? '').toLowerCase() === filterCategory.toLowerCase();
|
|
32
|
+
return matchesSearch && matchesStatus && matchesCategory;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (searchQuery.trim()) {
|
|
36
|
+
results = sortByRelevance(results, searchQuery, (p: any) => [p.title, p.author ?? '', p.category ?? '']);
|
|
37
|
+
} else if (sortConfig) {
|
|
38
|
+
results = [...results].sort((a: any, b: any) => {
|
|
39
|
+
const aVal = a[sortConfig.key] ?? '';
|
|
40
|
+
const bVal = b[sortConfig.key] ?? '';
|
|
41
|
+
const cmp = String(aVal).localeCompare(String(bVal));
|
|
42
|
+
return sortConfig.direction === 'asc' ? cmp : -cmp;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}, [posts, searchQuery, filterStatus, filterCategory, sortConfig]);
|
|
47
|
+
|
|
48
|
+
const handleSelectAll = (checked: boolean) => {
|
|
49
|
+
setSelectedPosts(checked ? filteredAndSorted.map((p: any) => p.id) : []);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleSelectPost = (id: number) => {
|
|
53
|
+
setSelectedPosts(prev => prev.includes(id) ? prev.filter(pid => pid !== id) : [...prev, id]);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleBulkDelete = async () => {
|
|
57
|
+
for (const id of selectedPosts) {
|
|
58
|
+
await cmsApi(`/collections/posts/${id}`, { method: 'DELETE' });
|
|
59
|
+
}
|
|
60
|
+
toast.success(`${selectedPosts.length} posts deleted`);
|
|
61
|
+
setSelectedPosts([]);
|
|
62
|
+
refetch();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleDelete = async (id: number) => {
|
|
66
|
+
await cmsApi(`/collections/posts/${id}`, { method: 'DELETE' });
|
|
67
|
+
toast.success('Post deleted');
|
|
68
|
+
setSelectedPosts(prev => prev.filter(pid => pid !== id));
|
|
69
|
+
refetch();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function SortHeader({ label, sortKey }: { label: string; sortKey: PostSortKey }) {
|
|
73
|
+
const active = sortConfig?.key === sortKey;
|
|
74
|
+
return (
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={() => setSortConfig(toggleSort(sortConfig, sortKey))}
|
|
78
|
+
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
|
79
|
+
>
|
|
80
|
+
{label}
|
|
81
|
+
{active ? (
|
|
82
|
+
sortConfig!.direction === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />
|
|
83
|
+
) : (
|
|
84
|
+
<ArrowUpDown className="w-3 h-3 text-gray-400" />
|
|
85
|
+
)}
|
|
86
|
+
</button>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (loading) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
|
|
93
|
+
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8 h-full flex flex-col">
|
|
100
|
+
{error && (
|
|
101
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
|
102
|
+
<AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
|
|
103
|
+
<span className="text-sm text-red-800 flex-1">{error}</span>
|
|
104
|
+
<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>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 gap-3">
|
|
109
|
+
<div>
|
|
110
|
+
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">Posts</h1>
|
|
111
|
+
<p className="text-sm text-gray-600">{filteredAndSorted.length} total posts</p>
|
|
112
|
+
</div>
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => onNavigate?.('/posts/new')}
|
|
115
|
+
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"
|
|
116
|
+
>
|
|
117
|
+
<Plus className="w-4 h-4" />
|
|
118
|
+
<span className="hidden sm:inline">New Post</span>
|
|
119
|
+
<span className="sm:hidden">New</span>
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="bg-white rounded-lg border border-gray-200 mb-4">
|
|
124
|
+
<div className="p-3 flex flex-col gap-3">
|
|
125
|
+
<div className="relative">
|
|
126
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
127
|
+
<input type="text" placeholder="Search posts..." 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" />
|
|
128
|
+
</div>
|
|
129
|
+
<div className="flex flex-col sm:flex-row gap-2">
|
|
130
|
+
<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">
|
|
131
|
+
<option value="all">All Status</option>
|
|
132
|
+
<option value="published">Published</option>
|
|
133
|
+
<option value="draft">Draft</option>
|
|
134
|
+
</select>
|
|
135
|
+
<select value={filterCategory} onChange={(e) => setFilterCategory(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">
|
|
136
|
+
<option value="all">All Categories</option>
|
|
137
|
+
<option value="technology">Technology</option>
|
|
138
|
+
<option value="design">Design</option>
|
|
139
|
+
<option value="business">Business</option>
|
|
140
|
+
</select>
|
|
141
|
+
<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">
|
|
142
|
+
<SlidersHorizontal className="w-4 h-4" />
|
|
143
|
+
<span className="hidden sm:inline">More Filters</span>
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{selectedPosts.length > 0 && (
|
|
150
|
+
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
|
|
151
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
|
152
|
+
<span className="text-sm text-blue-900">{selectedPosts.length} post{selectedPosts.length !== 1 ? 's' : ''} selected</span>
|
|
153
|
+
<div className="flex items-center gap-2">
|
|
154
|
+
<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>
|
|
155
|
+
<button type="button" onClick={() => setSelectedPosts([])} 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>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{filteredAndSorted.length === 0 && !error ? (
|
|
162
|
+
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center flex-1 flex flex-col items-center justify-center">
|
|
163
|
+
<p className="text-sm text-gray-500 mb-2">No posts yet</p>
|
|
164
|
+
<button onClick={() => onNavigate?.('/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>
|
|
165
|
+
</div>
|
|
166
|
+
) : (
|
|
167
|
+
<>
|
|
168
|
+
<div className="hidden md:block bg-white rounded-lg border border-gray-200 flex-1 overflow-hidden">
|
|
169
|
+
<div className="overflow-x-auto h-full">
|
|
170
|
+
<table className="w-full">
|
|
171
|
+
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0">
|
|
172
|
+
<tr>
|
|
173
|
+
<th className="w-8 px-3 py-2 text-left">
|
|
174
|
+
<input type="checkbox" checked={selectedPosts.length === filteredAndSorted.length && filteredAndSorted.length > 0} onChange={(e) => handleSelectAll(e.target.checked)} className="rounded border-gray-300" />
|
|
175
|
+
</th>
|
|
176
|
+
<th className="px-3 py-2 text-left"><SortHeader label="Title" sortKey="title" /></th>
|
|
177
|
+
<th className="px-3 py-2 text-left"><SortHeader label="Author" sortKey="author" /></th>
|
|
178
|
+
<th className="px-3 py-2 text-left"><SortHeader label="Category" sortKey="category" /></th>
|
|
179
|
+
<th className="px-3 py-2 text-left"><SortHeader label="Status" sortKey="status" /></th>
|
|
180
|
+
<th className="px-3 py-2 text-left"><SortHeader label="Date" sortKey="date" /></th>
|
|
181
|
+
<th className="px-3 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
|
|
182
|
+
</tr>
|
|
183
|
+
</thead>
|
|
184
|
+
<tbody className="divide-y divide-gray-200">
|
|
185
|
+
{filteredAndSorted.map((post: any) => (
|
|
186
|
+
<tr key={post.id} className="hover:bg-gray-50 transition-colors">
|
|
187
|
+
<td className="px-3 py-2">
|
|
188
|
+
<input type="checkbox" checked={selectedPosts.includes(post.id)} onChange={() => handleSelectPost(post.id)} className="rounded border-gray-300" />
|
|
189
|
+
</td>
|
|
190
|
+
<td className="px-3 py-2">
|
|
191
|
+
<button type="button" onClick={() => onNavigate?.(`/posts/${post.id}`)} className="font-medium text-gray-900 hover:text-blue-600 text-sm text-left">{post.title}</button>
|
|
192
|
+
</td>
|
|
193
|
+
<td className="px-3 py-2 text-sm text-gray-600">{post.author}</td>
|
|
194
|
+
<td className="px-3 py-2 text-sm text-gray-600">{post.category}</td>
|
|
195
|
+
<td className="px-3 py-2">
|
|
196
|
+
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${post.status === 'Published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>{post.status}</span>
|
|
197
|
+
</td>
|
|
198
|
+
<td className="px-3 py-2 text-sm text-gray-600">{post.date}</td>
|
|
199
|
+
<td className="px-3 py-2">
|
|
200
|
+
<div className="flex items-center gap-2">
|
|
201
|
+
<button type="button" onClick={() => onNavigate?.(`/posts/${post.id}`)} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
|
|
202
|
+
<Pencil className="w-4 h-4 text-gray-600" />
|
|
203
|
+
</button>
|
|
204
|
+
<button type="button" onClick={() => handleDelete(post.id)} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Delete">
|
|
205
|
+
<Trash2 className="w-4 h-4 text-red-600" />
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
</td>
|
|
209
|
+
</tr>
|
|
210
|
+
))}
|
|
211
|
+
</tbody>
|
|
212
|
+
</table>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div className="md:hidden bg-white rounded-lg border border-gray-200 flex-1 overflow-auto">
|
|
217
|
+
<div className="divide-y divide-gray-200">
|
|
218
|
+
{filteredAndSorted.map((post: any) => (
|
|
219
|
+
<div key={post.id} className="p-3">
|
|
220
|
+
<div className="flex items-start gap-3">
|
|
221
|
+
<input type="checkbox" checked={selectedPosts.includes(post.id)} onChange={() => handleSelectPost(post.id)} className="rounded border-gray-300 mt-1" />
|
|
222
|
+
<div className="flex-1 min-w-0">
|
|
223
|
+
<button type="button" onClick={() => onNavigate?.(`/posts/${post.id}`)} className="font-medium text-sm text-gray-900 hover:text-blue-600 block mb-1 text-left">{post.title}</button>
|
|
224
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-600 mb-2">
|
|
225
|
+
<span>{post.author}</span>
|
|
226
|
+
<span>•</span>
|
|
227
|
+
<span>{post.date}</span>
|
|
228
|
+
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${post.status === 'Published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>{post.status}</span>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
<button type="button" onClick={() => handleDelete(post.id)} className="p-1.5 hover:bg-gray-100 rounded transition-colors">
|
|
232
|
+
<Trash2 className="w-4 h-4 text-red-600" />
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|