@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.
- 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/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 +7 -2
- package/src/styles/theme.css +2 -1
- 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,293 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as Dialog from '@radix-ui/react-dialog';
|
|
4
|
+
import { Pencil, Plus, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown, ArrowRightLeft, Loader2, AlertTriangle } from 'lucide-react';
|
|
5
|
+
import { useState, useMemo, type FormEvent } from 'react';
|
|
6
|
+
import { toast } from 'sonner';
|
|
7
|
+
import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js';
|
|
8
|
+
import { useApiData } from '../lib/useApiData.js';
|
|
9
|
+
import { cmsApi } from '../lib/api.js';
|
|
10
|
+
|
|
11
|
+
type RedirectSortKey = 'from' | 'to' | 'type' | 'hits' | 'active';
|
|
12
|
+
|
|
13
|
+
export interface RedirectsProps {
|
|
14
|
+
onNavigate?: (path: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Redirects({ onNavigate }: RedirectsProps) {
|
|
18
|
+
const { data, loading, error, refetch } = useApiData<any[]>('/redirects');
|
|
19
|
+
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
20
|
+
const [newRedirect, setNewRedirect] = useState({ source: '', destination: '', type: '301' as '301' | '302' });
|
|
21
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
22
|
+
const [filterType, setFilterType] = useState('all');
|
|
23
|
+
const [sortConfig, setSortConfig] = useState<SortConfig<RedirectSortKey> | null>(null);
|
|
24
|
+
|
|
25
|
+
const redirects = data ?? [];
|
|
26
|
+
|
|
27
|
+
const filteredAndSorted = useMemo(() => {
|
|
28
|
+
let results = redirects.filter((r) => {
|
|
29
|
+
const matchesSearch =
|
|
30
|
+
r.from.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
31
|
+
r.to.toLowerCase().includes(searchQuery.toLowerCase());
|
|
32
|
+
const matchesType = filterType === 'all' || r.type === filterType;
|
|
33
|
+
return matchesSearch && matchesType;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (searchQuery.trim()) {
|
|
37
|
+
results = sortByRelevance(results, searchQuery, (r) => [r.from, r.to]);
|
|
38
|
+
} else if (sortConfig) {
|
|
39
|
+
results = [...results].sort((a, b) => {
|
|
40
|
+
let cmp: number;
|
|
41
|
+
if (sortConfig.key === 'hits') {
|
|
42
|
+
cmp = a.hits - b.hits;
|
|
43
|
+
} else if (sortConfig.key === 'active') {
|
|
44
|
+
cmp = Number(a.active) - Number(b.active);
|
|
45
|
+
} else {
|
|
46
|
+
cmp = String(a[sortConfig.key]).localeCompare(String(b[sortConfig.key]));
|
|
47
|
+
}
|
|
48
|
+
return sortConfig.direction === 'asc' ? cmp : -cmp;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return results;
|
|
52
|
+
}, [redirects, searchQuery, filterType, sortConfig]);
|
|
53
|
+
|
|
54
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
const res = await cmsApi('/redirects', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
body: JSON.stringify({ from: newRedirect.source, to: newRedirect.destination, type: newRedirect.type }),
|
|
59
|
+
});
|
|
60
|
+
if (res.error) {
|
|
61
|
+
toast.error(res.error);
|
|
62
|
+
} else {
|
|
63
|
+
toast.success('Redirect added');
|
|
64
|
+
refetch();
|
|
65
|
+
}
|
|
66
|
+
setShowAddDialog(false);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleDelete = async (id: number) => {
|
|
70
|
+
const res = await cmsApi(`/redirects/${id}`, { method: 'DELETE' });
|
|
71
|
+
if (res.error) {
|
|
72
|
+
toast.error(res.error);
|
|
73
|
+
} else {
|
|
74
|
+
toast.success('Redirect deleted');
|
|
75
|
+
refetch();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function SortHeader({ label, sortKey }: { label: string; sortKey: RedirectSortKey }) {
|
|
80
|
+
const active = sortConfig?.key === sortKey;
|
|
81
|
+
return (
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={() => setSortConfig(toggleSort(sortConfig, sortKey))}
|
|
85
|
+
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
|
86
|
+
>
|
|
87
|
+
{label}
|
|
88
|
+
{active ? (
|
|
89
|
+
sortConfig!.direction === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />
|
|
90
|
+
) : (
|
|
91
|
+
<ArrowUpDown className="w-3 h-3 text-gray-400" />
|
|
92
|
+
)}
|
|
93
|
+
</button>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (loading) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
|
|
100
|
+
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8">
|
|
107
|
+
{error && (
|
|
108
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
|
109
|
+
<AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
|
|
110
|
+
<span className="text-sm text-red-800 flex-1">{error}</span>
|
|
111
|
+
<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>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
<div className="mb-4 flex items-center justify-between">
|
|
116
|
+
<div>
|
|
117
|
+
<h1 className="mb-1 text-xl font-semibold text-gray-900 sm:text-2xl">Redirects</h1>
|
|
118
|
+
<p className="text-sm text-gray-600">{filteredAndSorted.length} total redirects</p>
|
|
119
|
+
</div>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={() => setShowAddDialog(true)}
|
|
123
|
+
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
|
|
124
|
+
>
|
|
125
|
+
<Plus className="h-4 w-4" />
|
|
126
|
+
Add Redirect
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="bg-white rounded-lg border border-gray-200 mb-4">
|
|
131
|
+
<div className="p-3 flex flex-col sm:flex-row gap-3">
|
|
132
|
+
<div className="relative flex-1">
|
|
133
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
134
|
+
<input
|
|
135
|
+
type="text"
|
|
136
|
+
placeholder="Search by source or destination URL..."
|
|
137
|
+
value={searchQuery}
|
|
138
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
139
|
+
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"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
<select
|
|
143
|
+
value={filterType}
|
|
144
|
+
onChange={(e) => setFilterType(e.target.value)}
|
|
145
|
+
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
146
|
+
>
|
|
147
|
+
<option value="all">All Types</option>
|
|
148
|
+
<option value="301">301 (Permanent)</option>
|
|
149
|
+
<option value="302">302 (Temporary)</option>
|
|
150
|
+
</select>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{filteredAndSorted.length === 0 && !loading ? (
|
|
155
|
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
156
|
+
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
|
157
|
+
<ArrowRightLeft className="w-6 h-6 text-gray-400" />
|
|
158
|
+
</div>
|
|
159
|
+
<h3 className="text-sm font-medium text-gray-900 mb-1">No redirects yet</h3>
|
|
160
|
+
<p className="text-sm text-gray-500">Add your first redirect to get started.</p>
|
|
161
|
+
</div>
|
|
162
|
+
) : (
|
|
163
|
+
<div className="rounded-lg border border-gray-200 bg-white">
|
|
164
|
+
<div className="overflow-x-auto">
|
|
165
|
+
<table className="w-full">
|
|
166
|
+
<thead className="border-b border-gray-200 bg-gray-50">
|
|
167
|
+
<tr>
|
|
168
|
+
<th className="px-4 py-2 text-left"><SortHeader label="Source URL" sortKey="from" /></th>
|
|
169
|
+
<th className="px-4 py-2 text-left"><SortHeader label="Destination URL" sortKey="to" /></th>
|
|
170
|
+
<th className="px-4 py-2 text-left"><SortHeader label="Type" sortKey="type" /></th>
|
|
171
|
+
<th className="px-4 py-2 text-left"><SortHeader label="Hits" sortKey="hits" /></th>
|
|
172
|
+
<th className="px-4 py-2 text-left"><SortHeader label="Status" sortKey="active" /></th>
|
|
173
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
|
|
174
|
+
</tr>
|
|
175
|
+
</thead>
|
|
176
|
+
<tbody className="divide-y divide-gray-200">
|
|
177
|
+
{filteredAndSorted.map((redirect) => (
|
|
178
|
+
<tr key={redirect.id} className="transition-colors hover:bg-gray-50">
|
|
179
|
+
<td className="px-4 py-3">
|
|
180
|
+
<code className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-900">{redirect.from}</code>
|
|
181
|
+
</td>
|
|
182
|
+
<td className="px-4 py-3">
|
|
183
|
+
<code className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-900">{redirect.to}</code>
|
|
184
|
+
</td>
|
|
185
|
+
<td className="px-4 py-3">
|
|
186
|
+
<span
|
|
187
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
188
|
+
redirect.type === '301' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'
|
|
189
|
+
}`}
|
|
190
|
+
>
|
|
191
|
+
{redirect.type}
|
|
192
|
+
</span>
|
|
193
|
+
</td>
|
|
194
|
+
<td className="px-4 py-3 text-sm text-gray-600">{redirect.hits.toLocaleString()}</td>
|
|
195
|
+
<td className="px-4 py-3">
|
|
196
|
+
<span
|
|
197
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
198
|
+
redirect.active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
|
199
|
+
}`}
|
|
200
|
+
>
|
|
201
|
+
{redirect.active ? 'Active' : 'Inactive'}
|
|
202
|
+
</span>
|
|
203
|
+
</td>
|
|
204
|
+
<td className="px-4 py-3">
|
|
205
|
+
<div className="flex items-center gap-2">
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={() => onNavigate?.(`/redirects/${redirect.id}/edit`)}
|
|
209
|
+
className="rounded p-1.5 transition-colors hover:bg-gray-100"
|
|
210
|
+
aria-label="Edit redirect"
|
|
211
|
+
>
|
|
212
|
+
<Pencil className="h-4 w-4 text-gray-600" />
|
|
213
|
+
</button>
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
onClick={() => handleDelete(redirect.id)}
|
|
217
|
+
className="rounded p-1.5 transition-colors hover:bg-gray-100"
|
|
218
|
+
aria-label="Delete redirect"
|
|
219
|
+
>
|
|
220
|
+
<Trash2 className="h-4 w-4 text-red-600" />
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
</td>
|
|
224
|
+
</tr>
|
|
225
|
+
))}
|
|
226
|
+
</tbody>
|
|
227
|
+
</table>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
<Dialog.Root open={showAddDialog} onOpenChange={setShowAddDialog}>
|
|
233
|
+
<Dialog.Portal>
|
|
234
|
+
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
|
235
|
+
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-lg">
|
|
236
|
+
<Dialog.Title className="mb-4 text-lg font-semibold text-gray-900">Add Redirect</Dialog.Title>
|
|
237
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
238
|
+
<div>
|
|
239
|
+
<label className="mb-1 block text-sm font-medium text-gray-700">Source URL</label>
|
|
240
|
+
<input
|
|
241
|
+
type="text"
|
|
242
|
+
value={newRedirect.source}
|
|
243
|
+
onChange={(e) => setNewRedirect({ ...newRedirect, source: e.target.value })}
|
|
244
|
+
placeholder="/old-page"
|
|
245
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
246
|
+
required
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
<div>
|
|
250
|
+
<label className="mb-1 block text-sm font-medium text-gray-700">Destination URL</label>
|
|
251
|
+
<input
|
|
252
|
+
type="text"
|
|
253
|
+
value={newRedirect.destination}
|
|
254
|
+
onChange={(e) => setNewRedirect({ ...newRedirect, destination: e.target.value })}
|
|
255
|
+
placeholder="/new-page"
|
|
256
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
257
|
+
required
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
<div>
|
|
261
|
+
<label className="mb-1 block text-sm font-medium text-gray-700">Redirect Type</label>
|
|
262
|
+
<select
|
|
263
|
+
value={newRedirect.type}
|
|
264
|
+
onChange={(e) => setNewRedirect({ ...newRedirect, type: e.target.value as '301' | '302' })}
|
|
265
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
266
|
+
>
|
|
267
|
+
<option value="301">301 (Permanent)</option>
|
|
268
|
+
<option value="302">302 (Temporary)</option>
|
|
269
|
+
</select>
|
|
270
|
+
</div>
|
|
271
|
+
<div className="flex items-center justify-end gap-3 pt-4">
|
|
272
|
+
<Dialog.Close asChild>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
className="rounded-lg border border-gray-300 px-4 py-2 text-sm transition-colors hover:bg-gray-50"
|
|
276
|
+
>
|
|
277
|
+
Cancel
|
|
278
|
+
</button>
|
|
279
|
+
</Dialog.Close>
|
|
280
|
+
<button
|
|
281
|
+
type="submit"
|
|
282
|
+
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
|
|
283
|
+
>
|
|
284
|
+
Add Redirect
|
|
285
|
+
</button>
|
|
286
|
+
</div>
|
|
287
|
+
</form>
|
|
288
|
+
</Dialog.Content>
|
|
289
|
+
</Dialog.Portal>
|
|
290
|
+
</Dialog.Root>
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|