@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,216 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
LayoutDashboard,
|
|
5
|
+
FileText,
|
|
6
|
+
File,
|
|
7
|
+
Image,
|
|
8
|
+
Settings,
|
|
9
|
+
ChevronLeft,
|
|
10
|
+
ChevronRight,
|
|
11
|
+
ClipboardList,
|
|
12
|
+
Users,
|
|
13
|
+
Search as SearchIcon,
|
|
14
|
+
Briefcase,
|
|
15
|
+
FolderOpen,
|
|
16
|
+
BookOpen,
|
|
17
|
+
HelpCircle,
|
|
18
|
+
Newspaper,
|
|
19
|
+
} from 'lucide-react';
|
|
20
|
+
import type { LucideIcon } from 'lucide-react';
|
|
21
|
+
|
|
22
|
+
function ActuateLogo({ className }: { className?: string }) {
|
|
23
|
+
return (
|
|
24
|
+
<svg viewBox="0 0 40 44" fill="none" xmlns="http://www.w3.org/2000/svg" className={className} aria-hidden="true">
|
|
25
|
+
{/* Upward arrow / chevron */}
|
|
26
|
+
<polygon points="20,2 6,18 12,18 20,8 28,18 34,18" fill="#E8646A" />
|
|
27
|
+
{/* Three vertical bars */}
|
|
28
|
+
<rect x="11" y="20" width="4" height="22" rx="1" fill="#E8646A" />
|
|
29
|
+
<rect x="18" y="20" width="4" height="22" rx="1" fill="#E8646A" />
|
|
30
|
+
<rect x="25" y="20" width="4" height="22" rx="1" fill="#E8646A" />
|
|
31
|
+
</svg>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ActuateWordmark({ className }: { className?: string }) {
|
|
36
|
+
return (
|
|
37
|
+
<svg viewBox="0 0 170 44" fill="none" xmlns="http://www.w3.org/2000/svg" className={className} aria-hidden="true">
|
|
38
|
+
<polygon points="20,2 6,18 12,18 20,8 28,18 34,18" fill="#E8646A" />
|
|
39
|
+
<rect x="11" y="20" width="4" height="22" rx="1" fill="#E8646A" />
|
|
40
|
+
<rect x="18" y="20" width="4" height="22" rx="1" fill="#E8646A" />
|
|
41
|
+
<rect x="25" y="20" width="4" height="22" rx="1" fill="#E8646A" />
|
|
42
|
+
<text x="44" y="25" fontFamily="system-ui, sans-serif" fontSize="17" fontWeight="600" letterSpacing="1.5" fill="#E8646A">ACTUATE</text>
|
|
43
|
+
<text x="44" y="40" fontFamily="system-ui, sans-serif" fontSize="10" fontWeight="500" letterSpacing="4" fill="#9CA3AF">MEDIA</text>
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ICON_MAP: Record<string, LucideIcon> = {
|
|
49
|
+
file: File,
|
|
50
|
+
'file-text': FileText,
|
|
51
|
+
briefcase: Briefcase,
|
|
52
|
+
folder: FolderOpen,
|
|
53
|
+
book: BookOpen,
|
|
54
|
+
help: HelpCircle,
|
|
55
|
+
newspaper: Newspaper,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean }) {
|
|
59
|
+
const branding = config?.admin?.branding;
|
|
60
|
+
const customLogo = branding?.logo;
|
|
61
|
+
const brandName = branding?.name;
|
|
62
|
+
|
|
63
|
+
if (collapsed) {
|
|
64
|
+
if (customLogo) {
|
|
65
|
+
return <img src={customLogo} alt={brandName ?? 'Admin'} className="w-8 h-8 object-contain" />;
|
|
66
|
+
}
|
|
67
|
+
return <ActuateLogo className="w-8 h-8" />;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (customLogo) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex items-center gap-2.5 min-w-0">
|
|
73
|
+
<img src={customLogo} alt={brandName ?? 'Admin'} className="h-8 w-auto object-contain shrink-0" />
|
|
74
|
+
{brandName && (
|
|
75
|
+
<span className="text-sm font-semibold text-sidebar-foreground truncate">{brandName}</span>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (brandName) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex items-center gap-2.5 min-w-0">
|
|
84
|
+
<ActuateLogo className="w-7 h-7 shrink-0" />
|
|
85
|
+
<span className="text-sm font-semibold text-sidebar-foreground truncate">{brandName}</span>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return <ActuateWordmark className="h-8 w-auto" />;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const defaultNavItems = [
|
|
94
|
+
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
|
95
|
+
{ path: '/pages', label: 'Pages', icon: File },
|
|
96
|
+
{ path: '/posts', label: 'Posts', icon: FileText },
|
|
97
|
+
{ path: '/media', label: 'Media', icon: Image },
|
|
98
|
+
{ path: '/forms', label: 'Forms', icon: ClipboardList },
|
|
99
|
+
{ path: '/seo', label: 'SEO', icon: SearchIcon },
|
|
100
|
+
{ path: '/users', label: 'Users', icon: Users },
|
|
101
|
+
{ path: '/settings', label: 'Settings', icon: Settings },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
export interface SidebarProps {
|
|
105
|
+
collapsed: boolean;
|
|
106
|
+
onToggleCollapse: () => void;
|
|
107
|
+
currentPath: string;
|
|
108
|
+
onNavigate: (path: string) => void;
|
|
109
|
+
config?: any;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function Sidebar({ collapsed, onToggleCollapse, currentPath, onNavigate, config }: SidebarProps) {
|
|
113
|
+
const navItems = buildNavItems(config);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<aside
|
|
117
|
+
className={`h-full bg-sidebar border-r border-sidebar-border transition-all duration-200 ${
|
|
118
|
+
collapsed ? 'w-20' : 'w-64'
|
|
119
|
+
}`}
|
|
120
|
+
>
|
|
121
|
+
<div
|
|
122
|
+
className={`flex items-center h-14 border-b border-sidebar-border px-4 ${
|
|
123
|
+
collapsed ? 'justify-center' : 'justify-between'
|
|
124
|
+
}`}
|
|
125
|
+
>
|
|
126
|
+
{!collapsed && (
|
|
127
|
+
<BrandLogo config={config} collapsed={false} />
|
|
128
|
+
)}
|
|
129
|
+
{collapsed && (
|
|
130
|
+
<BrandLogo config={config} collapsed={true} />
|
|
131
|
+
)}
|
|
132
|
+
<button
|
|
133
|
+
onClick={onToggleCollapse}
|
|
134
|
+
className="hidden lg:block p-2 hover:bg-sidebar-accent rounded-lg transition-colors shrink-0"
|
|
135
|
+
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
136
|
+
>
|
|
137
|
+
{collapsed ? (
|
|
138
|
+
<ChevronRight className="w-4 h-4 text-sidebar-foreground" />
|
|
139
|
+
) : (
|
|
140
|
+
<ChevronLeft className="w-4 h-4 text-sidebar-foreground" />
|
|
141
|
+
)}
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<nav className="p-3 space-y-1">
|
|
146
|
+
{navItems.map((item) => {
|
|
147
|
+
const Icon = item.icon;
|
|
148
|
+
const isActive =
|
|
149
|
+
currentPath === item.path ||
|
|
150
|
+
(item.path !== '/' && currentPath.startsWith(item.path));
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<button
|
|
154
|
+
key={item.path}
|
|
155
|
+
onClick={() => onNavigate(item.path)}
|
|
156
|
+
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors w-full text-left ${
|
|
157
|
+
isActive
|
|
158
|
+
? 'bg-sidebar-accent text-sidebar-primary'
|
|
159
|
+
: 'text-sidebar-foreground hover:bg-sidebar-accent'
|
|
160
|
+
} ${collapsed ? 'justify-center' : ''}`}
|
|
161
|
+
title={collapsed ? item.label : ''}
|
|
162
|
+
>
|
|
163
|
+
<Icon className="w-5 h-5 shrink-0" />
|
|
164
|
+
{!collapsed && (
|
|
165
|
+
<span className="text-sm font-medium">{item.label}</span>
|
|
166
|
+
)}
|
|
167
|
+
</button>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</nav>
|
|
171
|
+
</aside>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolveIcon(collection: any): LucideIcon {
|
|
176
|
+
const mapped = collection.admin?.icon ? ICON_MAP[collection.admin.icon] : undefined;
|
|
177
|
+
if (mapped) return mapped;
|
|
178
|
+
return collection.type === 'page' ? File : FileText;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildNavItems(config: any) {
|
|
182
|
+
if (!config?.collections) return defaultNavItems;
|
|
183
|
+
|
|
184
|
+
const raw = config.collections;
|
|
185
|
+
const collectionsList: any[] = Array.isArray(raw) ? raw : Object.values(raw);
|
|
186
|
+
|
|
187
|
+
const visible = collectionsList.filter((c) => !c.admin?.hidden);
|
|
188
|
+
|
|
189
|
+
const pages = visible.filter((c) => c.type === 'page');
|
|
190
|
+
const posts = visible.filter((c) => c.type === 'post');
|
|
191
|
+
const other = visible.filter((c) => c.type !== 'page' && c.type !== 'post');
|
|
192
|
+
|
|
193
|
+
const sorted = [...pages, ...posts, ...other];
|
|
194
|
+
|
|
195
|
+
const items: typeof defaultNavItems = [
|
|
196
|
+
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
for (const collection of sorted) {
|
|
200
|
+
items.push({
|
|
201
|
+
label: collection.labels?.plural ?? collection.slug,
|
|
202
|
+
path: `/${collection.slug}`,
|
|
203
|
+
icon: resolveIcon(collection),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
items.push(
|
|
208
|
+
{ path: '/media', label: 'Media', icon: Image },
|
|
209
|
+
{ path: '/forms', label: 'Forms', icon: ClipboardList },
|
|
210
|
+
{ path: '/seo', label: 'SEO', icon: SearchIcon },
|
|
211
|
+
{ path: '/users', label: 'Users', icon: Users },
|
|
212
|
+
{ path: '/settings', label: 'Settings', icon: Settings },
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
return items;
|
|
216
|
+
}
|
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const DEFAULT_BASE = '/api/cms';
|
|
2
|
+
|
|
3
|
+
let basePath = DEFAULT_BASE;
|
|
4
|
+
|
|
5
|
+
export function setApiBase(path: string) {
|
|
6
|
+
basePath = path;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getCsrfToken(): string {
|
|
10
|
+
if (typeof document === 'undefined') return '';
|
|
11
|
+
const match = document.cookie.match(/(?:^|;\s*)actuate_csrf=([^;]*)/);
|
|
12
|
+
return match?.[1] ?? '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function ensureCsrfToken(): Promise<void> {
|
|
16
|
+
if (getCsrfToken()) return;
|
|
17
|
+
try {
|
|
18
|
+
await fetch(`${basePath}/auth/csrf`, { credentials: 'include' });
|
|
19
|
+
} catch { /* best-effort */ }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getActiveLocale(): string | null {
|
|
23
|
+
if (typeof localStorage === 'undefined') return null;
|
|
24
|
+
return localStorage.getItem('actuate-locale');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function appendLocaleParam(endpoint: string): string {
|
|
28
|
+
const locale = getActiveLocale();
|
|
29
|
+
if (!locale) return endpoint;
|
|
30
|
+
const separator = endpoint.includes('?') ? '&' : '?';
|
|
31
|
+
return `${endpoint}${separator}locale=${encodeURIComponent(locale)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function cmsApi<T = unknown>(
|
|
35
|
+
endpoint: string,
|
|
36
|
+
options: RequestInit = {}
|
|
37
|
+
): Promise<{ data?: T; error?: string; status: number }> {
|
|
38
|
+
const url = `${basePath}${appendLocaleParam(endpoint)}`;
|
|
39
|
+
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
|
|
40
|
+
|
|
41
|
+
const headers: Record<string, string> = {
|
|
42
|
+
...(options.headers as Record<string, string> || {}),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (!isFormData) {
|
|
46
|
+
headers['Content-Type'] = 'application/json';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const method = (options.method ?? 'GET').toUpperCase();
|
|
50
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
51
|
+
const csrf = getCsrfToken();
|
|
52
|
+
if (csrf) headers['x-csrf-token'] = csrf;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(url, { ...options, headers, credentials: 'include' });
|
|
57
|
+
const json = await res.json().catch(() => ({}));
|
|
58
|
+
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
return { error: json.error || `Request failed (${res.status})`, status: res.status };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { data: json.data ?? json, status: res.status };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { error: err instanceof Error ? err.message : 'Network error', status: 0 };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
type SortDirection = 'asc' | 'desc';
|
|
2
|
+
|
|
3
|
+
export interface SortConfig<K extends string = string> {
|
|
4
|
+
key: K;
|
|
5
|
+
direction: SortDirection;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function scoreRelevance(text: string, query: string): number {
|
|
9
|
+
if (!query) return 0;
|
|
10
|
+
const lower = text.toLowerCase();
|
|
11
|
+
const q = query.toLowerCase();
|
|
12
|
+
if (lower === q) return 100;
|
|
13
|
+
if (lower.startsWith(q)) return 80;
|
|
14
|
+
const wordBoundary = new RegExp(`\\b${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
|
|
15
|
+
if (wordBoundary.test(lower)) return 60;
|
|
16
|
+
if (lower.includes(q)) return 40;
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sortByRelevance<T>(
|
|
21
|
+
items: T[],
|
|
22
|
+
query: string,
|
|
23
|
+
getSearchFields: (item: T) => string[],
|
|
24
|
+
): T[] {
|
|
25
|
+
if (!query.trim()) return items;
|
|
26
|
+
return [...items].sort((a, b) => {
|
|
27
|
+
const scoreA = Math.max(...getSearchFields(a).map(f => scoreRelevance(f, query)));
|
|
28
|
+
const scoreB = Math.max(...getSearchFields(b).map(f => scoreRelevance(f, query)));
|
|
29
|
+
return scoreB - scoreA;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sortByColumn<T>(
|
|
34
|
+
items: T[],
|
|
35
|
+
sortConfig: SortConfig | null,
|
|
36
|
+
getValue: (item: T, key: string) => string | number,
|
|
37
|
+
): T[] {
|
|
38
|
+
if (!sortConfig) return items;
|
|
39
|
+
return [...items].sort((a, b) => {
|
|
40
|
+
const aVal = getValue(a, sortConfig.key);
|
|
41
|
+
const bVal = getValue(b, sortConfig.key);
|
|
42
|
+
const cmp = typeof aVal === 'number' && typeof bVal === 'number'
|
|
43
|
+
? aVal - bVal
|
|
44
|
+
: String(aVal).localeCompare(String(bVal));
|
|
45
|
+
return sortConfig.direction === 'asc' ? cmp : -cmp;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function toggleSort<K extends string>(
|
|
50
|
+
current: SortConfig<K> | null,
|
|
51
|
+
key: K,
|
|
52
|
+
): SortConfig<K> {
|
|
53
|
+
if (current?.key === key) {
|
|
54
|
+
return current.direction === 'asc'
|
|
55
|
+
? { key, direction: 'desc' }
|
|
56
|
+
: { key, direction: 'asc' };
|
|
57
|
+
}
|
|
58
|
+
return { key, direction: 'asc' };
|
|
59
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
3
|
+
import { cmsApi } from './api.js';
|
|
4
|
+
|
|
5
|
+
export interface UseApiDataOptions {
|
|
6
|
+
maxRetries?: number;
|
|
7
|
+
/** Base delay in ms for exponential backoff (default 1000). Actual delay = base * 2^attempt. */
|
|
8
|
+
retryBaseMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UseApiDataResult<T> {
|
|
12
|
+
data: T | null;
|
|
13
|
+
loading: boolean;
|
|
14
|
+
error: string | null;
|
|
15
|
+
/** True when all retries are exhausted and the request permanently failed. */
|
|
16
|
+
exhausted: boolean;
|
|
17
|
+
refetch: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useApiData<T>(
|
|
21
|
+
endpoint: string | null,
|
|
22
|
+
defaultValue: T | null = null,
|
|
23
|
+
options: UseApiDataOptions = {},
|
|
24
|
+
): UseApiDataResult<T> {
|
|
25
|
+
const { maxRetries = 3, retryBaseMs = 1000 } = options;
|
|
26
|
+
const [data, setData] = useState<T | null>(defaultValue);
|
|
27
|
+
const [loading, setLoading] = useState(endpoint !== null);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [exhausted, setExhausted] = useState(false);
|
|
30
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
31
|
+
const retryCountRef = useRef(0);
|
|
32
|
+
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
33
|
+
|
|
34
|
+
const fetchData = useCallback(async (isRetry = false) => {
|
|
35
|
+
if (!endpoint) {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
abortRef.current?.abort();
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
abortRef.current = controller;
|
|
43
|
+
|
|
44
|
+
if (!isRetry) {
|
|
45
|
+
retryCountRef.current = 0;
|
|
46
|
+
setExhausted(false);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setLoading(true);
|
|
50
|
+
setError(null);
|
|
51
|
+
|
|
52
|
+
const res = await cmsApi<T>(endpoint, { signal: controller.signal });
|
|
53
|
+
|
|
54
|
+
if (controller.signal.aborted) return;
|
|
55
|
+
|
|
56
|
+
if (res.error) {
|
|
57
|
+
if (retryCountRef.current < maxRetries) {
|
|
58
|
+
const delay = retryBaseMs * Math.pow(2, retryCountRef.current);
|
|
59
|
+
retryCountRef.current += 1;
|
|
60
|
+
retryTimerRef.current = setTimeout(() => fetchData(true), delay);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setError(res.error);
|
|
64
|
+
setExhausted(true);
|
|
65
|
+
setLoading(false);
|
|
66
|
+
} else {
|
|
67
|
+
setData(res.data ?? null);
|
|
68
|
+
setLoading(false);
|
|
69
|
+
retryCountRef.current = 0;
|
|
70
|
+
}
|
|
71
|
+
}, [endpoint, maxRetries, retryBaseMs]);
|
|
72
|
+
|
|
73
|
+
const manualRefetch = useCallback(() => {
|
|
74
|
+
if (retryTimerRef.current) {
|
|
75
|
+
clearTimeout(retryTimerRef.current);
|
|
76
|
+
retryTimerRef.current = null;
|
|
77
|
+
}
|
|
78
|
+
retryCountRef.current = 0;
|
|
79
|
+
setExhausted(false);
|
|
80
|
+
fetchData(false);
|
|
81
|
+
}, [fetchData]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
fetchData(false);
|
|
85
|
+
return () => {
|
|
86
|
+
abortRef.current?.abort();
|
|
87
|
+
if (retryTimerRef.current) {
|
|
88
|
+
clearTimeout(retryTimerRef.current);
|
|
89
|
+
retryTimerRef.current = null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}, [fetchData]);
|
|
93
|
+
|
|
94
|
+
return { data, loading, error, exhausted, refetch: manualRefetch };
|
|
95
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
interface RouteParams {
|
|
6
|
+
[key: string]: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function stripBase(pathname: string, basePath: string): string {
|
|
10
|
+
if (pathname.startsWith(basePath)) {
|
|
11
|
+
const rest = pathname.slice(basePath.length);
|
|
12
|
+
return rest === '' || rest === '/' ? '/' : rest.startsWith('/') ? rest : `/${rest}`;
|
|
13
|
+
}
|
|
14
|
+
return '/';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
18
|
+
const [currentPath, setCurrentPath] = useState(serverPath);
|
|
19
|
+
const baseRef = useRef(basePath);
|
|
20
|
+
baseRef.current = basePath;
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (typeof window !== 'undefined') {
|
|
24
|
+
const browserPath = stripBase(window.location.pathname, basePath);
|
|
25
|
+
if (browserPath !== currentPath) {
|
|
26
|
+
setCurrentPath(browserPath);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
function onPopState() {
|
|
33
|
+
setCurrentPath(stripBase(window.location.pathname, baseRef.current));
|
|
34
|
+
}
|
|
35
|
+
window.addEventListener('popstate', onPopState);
|
|
36
|
+
return () => window.removeEventListener('popstate', onPopState);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const navigate = useCallback((path: string) => {
|
|
40
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
41
|
+
setCurrentPath(normalizedPath);
|
|
42
|
+
|
|
43
|
+
const fullUrl = normalizedPath === '/'
|
|
44
|
+
? baseRef.current
|
|
45
|
+
: `${baseRef.current}${normalizedPath}`;
|
|
46
|
+
window.history.pushState({ adminPath: normalizedPath }, '', fullUrl);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const matchRoute = useCallback(
|
|
50
|
+
(pattern: string): RouteParams | null => {
|
|
51
|
+
const patternParts = pattern.split('/').filter(Boolean);
|
|
52
|
+
const pathParts = currentPath.split('/').filter(Boolean);
|
|
53
|
+
|
|
54
|
+
if (pattern === '/' && pathParts.length === 0) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (patternParts.length !== pathParts.length) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const params: RouteParams = {};
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
65
|
+
const patternPart = patternParts[i]!;
|
|
66
|
+
const pathPart = pathParts[i]!;
|
|
67
|
+
|
|
68
|
+
if (patternPart.startsWith(':')) {
|
|
69
|
+
params[patternPart.slice(1)] = pathPart;
|
|
70
|
+
} else if (patternPart !== pathPart) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return params;
|
|
76
|
+
},
|
|
77
|
+
[currentPath],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return { currentPath, navigate, matchRoute };
|
|
81
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Build-time input for pre-compiling admin CSS.
|
|
3
|
+
* Tailwind CLI scans the @source paths and emits a self-contained stylesheet
|
|
4
|
+
* with all utilities (including responsive variants) baked in.
|
|
5
|
+
*
|
|
6
|
+
* Consumers import the compiled output:
|
|
7
|
+
* import '@actuate-media/cms-admin/styles/precompiled.css'
|
|
8
|
+
*/
|
|
9
|
+
@import "tailwindcss";
|
|
10
|
+
@source "../../src/**/*.{ts,tsx}";
|
|
11
|
+
@import "./theme.css";
|
package/src/styles/tailwind.css
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Actuate CMS admin styles.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/*
|
|
2
|
+
* Actuate CMS admin styles.
|
|
3
|
+
*
|
|
4
|
+
* RECOMMENDED: Import the pre-compiled stylesheet instead for zero-config:
|
|
5
|
+
* @import "@actuate-media/cms-admin/styles/precompiled.css";
|
|
6
|
+
*
|
|
7
|
+
* This file is for advanced users who want to customize the Tailwind build.
|
|
8
|
+
* If using this file, you must add @source to scan the admin components:
|
|
9
|
+
* @source "../../../node_modules/@actuate-media/cms-admin/src/**\/*.{ts,tsx}";
|
|
10
|
+
*/
|
|
11
|
+
@import "./theme.css";
|