@actuate-media/cms-admin 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +16 -10
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/actuate-admin.css +2 -0
  5. package/dist/lib/useApiData.d.ts +8 -1
  6. package/dist/lib/useApiData.d.ts.map +1 -1
  7. package/dist/lib/useApiData.js +39 -7
  8. package/dist/lib/useApiData.js.map +1 -1
  9. package/dist/views/Dashboard.d.ts.map +1 -1
  10. package/dist/views/Dashboard.js +8 -3
  11. package/dist/views/Dashboard.js.map +1 -1
  12. package/package.json +10 -5
  13. package/src/AdminRoot.tsx +312 -0
  14. package/src/__tests__/lib/search.test.ts +138 -0
  15. package/src/__tests__/lib/utils.test.ts +19 -0
  16. package/src/__tests__/router/match-route.test.ts +47 -0
  17. package/src/__tests__/router/strip-base.test.ts +30 -0
  18. package/src/components/Breadcrumbs.tsx +92 -0
  19. package/src/components/CommandPalette.tsx +384 -0
  20. package/src/components/ErrorBoundary.tsx +52 -0
  21. package/src/components/FocalPointPicker.tsx +54 -0
  22. package/src/components/FolderTree.tsx +427 -0
  23. package/src/components/LivePreview.tsx +136 -0
  24. package/src/components/LocaleProvider.tsx +51 -0
  25. package/src/components/LocaleSwitcher.tsx +51 -0
  26. package/src/components/MediaPickerModal.tsx +183 -0
  27. package/src/components/PresenceIndicator.tsx +71 -0
  28. package/src/components/SEOPanel.tsx +767 -0
  29. package/src/components/ThemeProvider.tsx +98 -0
  30. package/src/components/TipTapEditor.tsx +469 -0
  31. package/src/components/VersionHistory.tsx +167 -0
  32. package/src/components/ui/Avatar.tsx +42 -0
  33. package/src/components/ui/Badge.tsx +25 -0
  34. package/src/components/ui/Button.tsx +52 -0
  35. package/src/components/ui/CommandPalette.tsx +119 -0
  36. package/src/components/ui/ConfirmDialog.tsx +52 -0
  37. package/src/components/ui/DataTable.tsx +194 -0
  38. package/src/components/ui/EmptyState.tsx +29 -0
  39. package/src/components/ui/Modal.tsx +48 -0
  40. package/src/components/ui/Pagination.tsx +79 -0
  41. package/src/components/ui/SearchInput.tsx +44 -0
  42. package/src/components/ui/Skeleton.tsx +48 -0
  43. package/src/components/ui/Toast.tsx +66 -0
  44. package/src/components/ui/index.ts +24 -0
  45. package/src/fields/ArrayField.tsx +92 -0
  46. package/src/fields/BlockBuilderField.tsx +421 -0
  47. package/src/fields/DateField.tsx +41 -0
  48. package/src/fields/FieldRenderer.tsx +84 -0
  49. package/src/fields/GroupField.tsx +41 -0
  50. package/src/fields/MediaField.tsx +48 -0
  51. package/src/fields/NavBuilderField.tsx +78 -0
  52. package/src/fields/NumberField.tsx +45 -0
  53. package/src/fields/RelationshipField.tsx +245 -0
  54. package/src/fields/RichTextField.tsx +26 -0
  55. package/src/fields/SelectField.tsx +117 -0
  56. package/src/fields/SlugField.tsx +65 -0
  57. package/src/fields/TextField.tsx +48 -0
  58. package/src/fields/ToggleField.tsx +36 -0
  59. package/src/fields/block-types.ts +95 -0
  60. package/src/fields/index.ts +17 -0
  61. package/src/hooks/useContentLock.ts +52 -0
  62. package/src/hooks/useDebounce.ts +14 -0
  63. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  64. package/src/index.ts +55 -0
  65. package/src/layout/Header.tsx +135 -0
  66. package/src/layout/Layout.tsx +77 -0
  67. package/src/layout/Sidebar.tsx +216 -0
  68. package/src/lib/api.ts +67 -0
  69. package/src/lib/search.ts +59 -0
  70. package/src/lib/useApiData.ts +95 -0
  71. package/src/lib/utils.ts +6 -0
  72. package/src/router/index.ts +81 -0
  73. package/src/styles/build-input.css +11 -0
  74. package/src/styles/tailwind.css +7 -2
  75. package/src/styles/theme.css +2 -1
  76. package/src/views/CollectionList.tsx +270 -0
  77. package/src/views/Dashboard.tsx +207 -0
  78. package/src/views/DocumentEdit.tsx +377 -0
  79. package/src/views/FormEditor.tsx +533 -0
  80. package/src/views/FormSubmissions.tsx +316 -0
  81. package/src/views/Forms.tsx +106 -0
  82. package/src/views/Login.tsx +322 -0
  83. package/src/views/MediaBrowser.tsx +774 -0
  84. package/src/views/PageEditor.tsx +192 -0
  85. package/src/views/Pages.tsx +354 -0
  86. package/src/views/PostEditor.tsx +251 -0
  87. package/src/views/Posts.tsx +243 -0
  88. package/src/views/Redirects.tsx +293 -0
  89. package/src/views/SEO.tsx +458 -0
  90. package/src/views/Settings.tsx +811 -0
  91. package/src/views/SetupWizard.tsx +207 -0
  92. package/src/views/Users.tsx +282 -0
@@ -0,0 +1,312 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo, useEffect, useRef } from 'react';
4
+ import { Layout } from './layout/Layout.js';
5
+ import { useAdminRouter } from './router/index.js';
6
+ import { Dashboard } from './views/Dashboard.js';
7
+ import { CollectionList } from './views/CollectionList.js';
8
+ import { DocumentEdit } from './views/DocumentEdit.js';
9
+ import { MediaBrowser } from './views/MediaBrowser.js';
10
+ import { Settings } from './views/Settings.js';
11
+ import { Forms } from './views/Forms.js';
12
+ import { FormEditor } from './views/FormEditor.js';
13
+ import { FormSubmissions } from './views/FormSubmissions.js';
14
+ import { Users } from './views/Users.js';
15
+ import { SEO } from './views/SEO.js';
16
+ import { SetupWizard } from './views/SetupWizard.js';
17
+ import { Login } from './views/Login.js';
18
+ import { ErrorBoundary } from './components/ErrorBoundary.js';
19
+ import { ThemeProvider } from './components/ThemeProvider.js';
20
+ import { LocaleProvider } from './components/LocaleProvider.js';
21
+ import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts.js';
22
+
23
+ export interface AdminRootProps {
24
+ config: any;
25
+ session: any;
26
+ basePath?: string;
27
+ initialPath?: string;
28
+ setupRequired?: boolean;
29
+ onSetupComplete?: (data: { name: string; email: string; password: string }) => Promise<{ success: boolean; error?: string }>;
30
+ onLogin?: (email: string, password: string, captchaToken?: string) => Promise<{ success: boolean; error?: string }>;
31
+ captchaConfig?: { provider: 'recaptcha' | 'turnstile' | 'none'; siteKey: string | null };
32
+ }
33
+
34
+ function AdminShell({ config, session, basePath = '/admin', initialPath = '/', setupRequired, onSetupComplete, onLogin, captchaConfig }: AdminRootProps) {
35
+ const { currentPath, navigate, matchRoute } = useAdminRouter(basePath, initialPath);
36
+ const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
37
+
38
+ useBranding(config);
39
+ useAdminPageTitle(config, currentPath);
40
+
41
+ const shortcuts = useMemo(() => ({
42
+ 'mod+k': () => {
43
+ const searchInput = document.querySelector<HTMLInputElement>('input[placeholder*="Search"]');
44
+ searchInput?.focus();
45
+ },
46
+ 'mod+s': () => {
47
+ const saveBtn = document.querySelector<HTMLButtonElement>('[data-shortcut="save"]');
48
+ saveBtn?.click();
49
+ },
50
+ 'escape': () => {
51
+ const closeBtn = document.querySelector<HTMLButtonElement>('[data-shortcut="close"]');
52
+ closeBtn?.click();
53
+ },
54
+ 'mod+/': () => {
55
+ setShortcutHelpOpen(prev => !prev);
56
+ },
57
+ }), []);
58
+
59
+ useKeyboardShortcuts(shortcuts);
60
+
61
+ if (setupRequired && onSetupComplete) {
62
+ return <SetupWizard onComplete={onSetupComplete} siteName={config?.admin?.branding?.name ?? 'Actuate CMS'} />;
63
+ }
64
+
65
+ if (!session && !setupRequired) {
66
+ if (onLogin) {
67
+ return <Login onLogin={onLogin} onNavigate={navigate} captchaConfig={captchaConfig} />;
68
+ }
69
+ return (
70
+ <div className="min-h-screen flex items-center justify-center bg-background">
71
+ <div className="text-center">
72
+ <h1 className="text-xl font-semibold text-foreground mb-2">Unauthorized</h1>
73
+ <p className="text-sm text-muted-foreground">Please log in to access the admin panel.</p>
74
+ </div>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ const collectionSlugs = config?.collections
80
+ ? Object.values(config.collections as Record<string, { slug: string }>).map((c) => c.slug)
81
+ : ['pages', 'posts'];
82
+
83
+ function renderView() {
84
+ if (matchRoute('/')) {
85
+ return <Dashboard onNavigate={navigate} />;
86
+ }
87
+
88
+ for (const slug of collectionSlugs) {
89
+ const newMatch = matchRoute(`/${slug}/new`);
90
+ if (newMatch) {
91
+ return <DocumentEdit collectionSlug={slug} config={config} />;
92
+ }
93
+ const editMatch = matchRoute(`/${slug}/:id`);
94
+ if (editMatch?.id) {
95
+ return <DocumentEdit collectionSlug={slug} documentId={editMatch.id} config={config} />;
96
+ }
97
+ if (matchRoute(`/${slug}`)) {
98
+ return <CollectionList collectionSlug={slug} config={config} onNavigate={navigate} />;
99
+ }
100
+ }
101
+
102
+ const newCollMatch = matchRoute('/collections/:slug/new');
103
+ if (newCollMatch?.slug) {
104
+ return <DocumentEdit collectionSlug={newCollMatch.slug} config={config} />;
105
+ }
106
+
107
+ const editCollMatch = matchRoute('/collections/:slug/:id');
108
+ if (editCollMatch?.slug && editCollMatch.id) {
109
+ return <DocumentEdit collectionSlug={editCollMatch.slug} documentId={editCollMatch.id} config={config} />;
110
+ }
111
+
112
+ const collectionMatch = matchRoute('/collections/:slug');
113
+ if (collectionMatch?.slug) {
114
+ return <CollectionList collectionSlug={collectionMatch.slug} config={config} onNavigate={navigate} />;
115
+ }
116
+
117
+ if (matchRoute('/media')) {
118
+ return <MediaBrowser onNavigate={navigate} />;
119
+ }
120
+
121
+ if (matchRoute('/forms/new')) {
122
+ return <FormEditor onNavigate={navigate} />;
123
+ }
124
+ const formEdit = matchRoute('/forms/:id/edit');
125
+ if (formEdit?.id) {
126
+ return <FormEditor formId={formEdit.id} onNavigate={navigate} />;
127
+ }
128
+ const formSubmissions = matchRoute('/forms/:id/submissions');
129
+ if (formSubmissions?.id) {
130
+ return <FormSubmissions formId={formSubmissions.id} onNavigate={navigate} />;
131
+ }
132
+ if (matchRoute('/forms')) {
133
+ return <Forms onNavigate={navigate} />;
134
+ }
135
+
136
+ if (matchRoute('/seo/redirects')) {
137
+ return <SEO onNavigate={navigate} initialTab="redirects" />;
138
+ }
139
+ if (matchRoute('/seo/canonicals')) {
140
+ return <SEO onNavigate={navigate} initialTab="canonicals" />;
141
+ }
142
+ if (matchRoute('/seo/links')) {
143
+ return <SEO onNavigate={navigate} initialTab="links" />;
144
+ }
145
+ if (matchRoute('/seo')) {
146
+ return <SEO onNavigate={navigate} initialTab="pages" />;
147
+ }
148
+
149
+ if (matchRoute('/users')) {
150
+ return <Users onNavigate={navigate} />;
151
+ }
152
+
153
+ if (matchRoute('/settings')) {
154
+ return <Settings onNavigate={navigate} />;
155
+ }
156
+
157
+ return (
158
+ <div className="flex flex-col items-center justify-center min-h-[400px] text-center p-6">
159
+ <h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
160
+ <p className="text-muted-foreground mb-4">The page you are looking for does not exist.</p>
161
+ <button
162
+ onClick={() => navigate('/')}
163
+ className="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary rounded-md hover:opacity-90"
164
+ >
165
+ Back to Dashboard
166
+ </button>
167
+ </div>
168
+ );
169
+ }
170
+
171
+ return (
172
+ <Layout config={config} session={session} currentPath={currentPath} onNavigate={navigate}>
173
+ <ErrorBoundary>
174
+ {renderView()}
175
+ </ErrorBoundary>
176
+ {shortcutHelpOpen && <ShortcutHelp onClose={() => setShortcutHelpOpen(false)} />}
177
+ </Layout>
178
+ );
179
+ }
180
+
181
+ function ShortcutHelp({ onClose }: { onClose: () => void }) {
182
+ return (
183
+ <div
184
+ className="fixed inset-0 z-100 flex items-center justify-center bg-black/50"
185
+ onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
186
+ >
187
+ <div className="bg-card text-card-foreground rounded-xl shadow-2xl border border-border p-6 max-w-sm w-full mx-4">
188
+ <h3 className="text-lg font-semibold mb-4">Keyboard Shortcuts</h3>
189
+ <div className="space-y-3 text-sm">
190
+ {[
191
+ ['⌘ K', 'Open search'],
192
+ ['⌘ S', 'Save document'],
193
+ ['⌘ /', 'Toggle this help'],
194
+ ['Esc', 'Close panel / modal'],
195
+ ].map(([key, desc]) => (
196
+ <div key={key} className="flex items-center justify-between">
197
+ <span className="text-muted-foreground">{desc}</span>
198
+ <kbd className="px-2 py-1 text-xs font-mono bg-muted rounded">{key}</kbd>
199
+ </div>
200
+ ))}
201
+ </div>
202
+ <button
203
+ onClick={onClose}
204
+ className="mt-4 w-full py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90"
205
+ >
206
+ Close
207
+ </button>
208
+ </div>
209
+ </div>
210
+ );
211
+ }
212
+
213
+ function useBranding(config: any) {
214
+ const originalFaviconRef = useRef<string | null>(null);
215
+ const originalTitleRef = useRef<string | null>(null);
216
+ const injectedLinkRef = useRef<HTMLLinkElement | null>(null);
217
+
218
+ useEffect(() => {
219
+ const branding = config?.admin?.branding;
220
+ if (!branding) return;
221
+
222
+ // --- Favicon ---
223
+ if (branding.favicon) {
224
+ const existing = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
225
+ originalFaviconRef.current = existing?.href ?? null;
226
+
227
+ if (existing) {
228
+ existing.href = branding.favicon;
229
+ } else {
230
+ const link = document.createElement('link');
231
+ link.rel = 'icon';
232
+ link.href = branding.favicon;
233
+ document.head.appendChild(link);
234
+ injectedLinkRef.current = link;
235
+ }
236
+ }
237
+
238
+ // --- Document title ---
239
+ originalTitleRef.current = document.title;
240
+ if (branding.title) {
241
+ document.title = branding.title;
242
+ } else if (branding.name) {
243
+ document.title = `${branding.name} Admin`;
244
+ }
245
+
246
+ // --- Primary color ---
247
+ const adminEl = document.querySelector<HTMLElement>('.actuate-admin');
248
+ if (branding.primaryColor && adminEl) {
249
+ adminEl.style.setProperty('--primary', branding.primaryColor);
250
+ adminEl.style.setProperty('--sidebar-primary', branding.primaryColor);
251
+ }
252
+
253
+ return () => {
254
+ // Restore favicon
255
+ if (branding.favicon) {
256
+ const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
257
+ if (link && originalFaviconRef.current) {
258
+ link.href = originalFaviconRef.current;
259
+ }
260
+ if (injectedLinkRef.current) {
261
+ injectedLinkRef.current.remove();
262
+ injectedLinkRef.current = null;
263
+ }
264
+ }
265
+
266
+ // Restore title
267
+ if (originalTitleRef.current !== null) {
268
+ document.title = originalTitleRef.current;
269
+ }
270
+
271
+ // Remove primary color override
272
+ if (branding.primaryColor && adminEl) {
273
+ adminEl.style.removeProperty('--primary');
274
+ adminEl.style.removeProperty('--sidebar-primary');
275
+ }
276
+ };
277
+ }, [config]);
278
+ }
279
+
280
+ function useAdminPageTitle(config: any, currentPath: string) {
281
+ useEffect(() => {
282
+ const branding = config?.admin?.branding;
283
+ const baseName = branding?.title ?? (branding?.name ? `${branding.name} Admin` : null);
284
+ if (!baseName) return;
285
+
286
+ const segment = currentPath === '/' ? 'Dashboard' : currentPath.split('/').filter(Boolean)[0];
287
+ const pageName = segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : 'Dashboard';
288
+ document.title = `${pageName} — ${baseName}`;
289
+ }, [config, currentPath]);
290
+ }
291
+
292
+ const ISOLATION_STYLE: React.CSSProperties = {
293
+ position: 'fixed',
294
+ inset: '0',
295
+ zIndex: 50,
296
+ overflow: 'auto',
297
+ isolation: 'isolate',
298
+ };
299
+
300
+ export function AdminRoot(props: AdminRootProps) {
301
+ const defaultDarkMode = props.config?.admin?.branding?.darkMode;
302
+
303
+ return (
304
+ <div style={ISOLATION_STYLE} className="actuate-admin">
305
+ <ThemeProvider defaultDarkMode={defaultDarkMode}>
306
+ <LocaleProvider config={props.config}>
307
+ <AdminShell {...props} />
308
+ </LocaleProvider>
309
+ </ThemeProvider>
310
+ </div>
311
+ );
312
+ }
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ scoreRelevance,
4
+ sortByRelevance,
5
+ sortByColumn,
6
+ toggleSort,
7
+ } from '../../lib/search.js';
8
+
9
+ describe('scoreRelevance', () => {
10
+ it('returns 100 for exact match (case-insensitive)', () => {
11
+ expect(scoreRelevance('Hello', 'hello')).toBe(100);
12
+ expect(scoreRelevance('test', 'test')).toBe(100);
13
+ });
14
+
15
+ it('returns 80 when text starts with the query', () => {
16
+ expect(scoreRelevance('hello world', 'hel')).toBe(80);
17
+ expect(scoreRelevance('Alpha', 'al')).toBe(80);
18
+ });
19
+
20
+ it('returns 60 when query matches at a word boundary but not at start', () => {
21
+ expect(scoreRelevance('say hello', 'hello')).toBe(60);
22
+ });
23
+
24
+ it('returns 40 when query appears only as a substring without word-boundary match', () => {
25
+ expect(scoreRelevance('axxhello', 'hello')).toBe(40);
26
+ });
27
+
28
+ it('returns 0 when there is no match', () => {
29
+ expect(scoreRelevance('abc def', 'xyz')).toBe(0);
30
+ });
31
+
32
+ it('returns 0 for an empty query', () => {
33
+ expect(scoreRelevance('anything', '')).toBe(0);
34
+ });
35
+ });
36
+
37
+ describe('sortByRelevance', () => {
38
+ it('sorts items by best field relevance score descending', () => {
39
+ const items = [
40
+ { id: 'a', title: 'zzz' },
41
+ { id: 'b', title: 'hello' },
42
+ { id: 'c', title: 'say hello' },
43
+ ];
44
+ const sorted = sortByRelevance(items, 'hello', item => [item.title]);
45
+ // exact match > word-boundary match > no match
46
+ expect(sorted.map(i => i.id)).toEqual(['b', 'c', 'a']);
47
+ });
48
+
49
+ it('returns the original array reference for an empty query', () => {
50
+ const items = [{ id: '1' }, { id: '2' }];
51
+ const result = sortByRelevance(items, '', item => [item.id]);
52
+ expect(result).toBe(items);
53
+ });
54
+
55
+ it('returns the original array when query is whitespace-only', () => {
56
+ const items = [{ id: '1' }];
57
+ expect(sortByRelevance(items, ' ', item => [item.id])).toBe(items);
58
+ });
59
+
60
+ it('uses the maximum score across multiple search fields', () => {
61
+ const items = [
62
+ { id: 'low', a: 'x', b: 'x' },
63
+ { id: 'high', a: 'x', b: 'exact' },
64
+ ];
65
+ const sorted = sortByRelevance(items, 'exact', item => [item.a, item.b]);
66
+ expect(sorted[0]!.id).toBe('high');
67
+ });
68
+ });
69
+
70
+ describe('sortByColumn', () => {
71
+ it('returns the original array when sortConfig is null', () => {
72
+ const items = [{ n: 2 }, { n: 1 }];
73
+ expect(sortByColumn(items, null, (item, key) => item[key as 'n'])).toBe(items);
74
+ });
75
+
76
+ it('sorts strings ascending', () => {
77
+ const items = [{ name: 'b' }, { name: 'a' }, { name: 'c' }];
78
+ const sorted = sortByColumn(
79
+ items,
80
+ { key: 'name', direction: 'asc' },
81
+ (item, key) => item[key as 'name'],
82
+ );
83
+ expect(sorted.map(i => i.name)).toEqual(['a', 'b', 'c']);
84
+ });
85
+
86
+ it('sorts strings descending', () => {
87
+ const items = [{ name: 'a' }, { name: 'c' }, { name: 'b' }];
88
+ const sorted = sortByColumn(
89
+ items,
90
+ { key: 'name', direction: 'desc' },
91
+ (item, key) => item[key as 'name'],
92
+ );
93
+ expect(sorted.map(i => i.name)).toEqual(['c', 'b', 'a']);
94
+ });
95
+
96
+ it('sorts numbers ascending and descending', () => {
97
+ const items = [{ v: 3 }, { v: 1 }, { v: 2 }];
98
+ const asc = sortByColumn(
99
+ items,
100
+ { key: 'v', direction: 'asc' },
101
+ (item, key) => item[key as 'v'],
102
+ );
103
+ expect(asc.map(i => i.v)).toEqual([1, 2, 3]);
104
+ const desc = sortByColumn(
105
+ items,
106
+ { key: 'v', direction: 'desc' },
107
+ (item, key) => item[key as 'v'],
108
+ );
109
+ expect(desc.map(i => i.v)).toEqual([3, 2, 1]);
110
+ });
111
+ });
112
+
113
+ describe('toggleSort', () => {
114
+ it('returns ascending sort when current is null', () => {
115
+ expect(toggleSort(null, 'title')).toEqual({ key: 'title', direction: 'asc' });
116
+ });
117
+
118
+ it('toggles same key from asc to desc', () => {
119
+ expect(toggleSort({ key: 'title', direction: 'asc' }, 'title')).toEqual({
120
+ key: 'title',
121
+ direction: 'desc',
122
+ });
123
+ });
124
+
125
+ it('toggles same key from desc to asc', () => {
126
+ expect(toggleSort({ key: 'title', direction: 'desc' }, 'title')).toEqual({
127
+ key: 'title',
128
+ direction: 'asc',
129
+ });
130
+ });
131
+
132
+ it('starts ascending when switching to a different key', () => {
133
+ expect(toggleSort({ key: 'foo', direction: 'desc' }, 'bar')).toEqual({
134
+ key: 'bar',
135
+ direction: 'asc',
136
+ });
137
+ });
138
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { cn } from '../../lib/utils.js';
3
+
4
+ describe('cn', () => {
5
+ it('merges multiple class names into a single string', () => {
6
+ expect(cn('foo', 'bar')).toBe('foo bar');
7
+ expect(cn('a', 'b', 'c')).toBe('a b c');
8
+ });
9
+
10
+ it('omits falsy conditional classes', () => {
11
+ expect(cn('base', false && 'hidden', 'visible')).toBe('base visible');
12
+ expect(cn('x', undefined, null, 0, 'y')).toBe('x y');
13
+ });
14
+
15
+ it('resolves conflicting Tailwind utilities in favor of the last one', () => {
16
+ expect(cn('p-4', 'p-2')).toBe('p-2');
17
+ expect(cn('text-sm', 'text-lg')).toBe('text-lg');
18
+ });
19
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ function matchRoute(currentPath: string, pattern: string): Record<string, string> | null {
4
+ const patternParts = pattern.split('/').filter(Boolean);
5
+ const pathParts = currentPath.split('/').filter(Boolean);
6
+ if (pattern === '/' && pathParts.length === 0) return {};
7
+ if (patternParts.length !== pathParts.length) return null;
8
+ const params: Record<string, string> = {};
9
+ for (let i = 0; i < patternParts.length; i++) {
10
+ const patternPart = patternParts[i]!;
11
+ const pathPart = pathParts[i]!;
12
+ if (patternPart.startsWith(':')) {
13
+ params[patternPart.slice(1)] = pathPart;
14
+ } else if (patternPart !== pathPart) {
15
+ return null;
16
+ }
17
+ }
18
+ return params;
19
+ }
20
+
21
+ describe('matchRoute', () => {
22
+ it('matches the root pattern against an empty path', () => {
23
+ expect(matchRoute('/', '/')).toEqual({});
24
+ });
25
+
26
+ it('matches static multi-segment paths', () => {
27
+ expect(matchRoute('/collections/posts', '/collections/posts')).toEqual({});
28
+ });
29
+
30
+ it('extracts named parameters from dynamic segments', () => {
31
+ expect(matchRoute('/collections/posts/abc-123', '/collections/posts/:id')).toEqual({
32
+ id: 'abc-123',
33
+ });
34
+ expect(
35
+ matchRoute('/users/u1/settings', '/users/:userId/settings'),
36
+ ).toEqual({ userId: 'u1' });
37
+ });
38
+
39
+ it('returns null when a static segment does not match', () => {
40
+ expect(matchRoute('/collections/pages', '/collections/posts')).toBeNull();
41
+ });
42
+
43
+ it('returns null when segment counts differ', () => {
44
+ expect(matchRoute('/a', '/a/b')).toBeNull();
45
+ expect(matchRoute('/a/b', '/a')).toBeNull();
46
+ });
47
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ // Copy of the internal stripBase function for testing
4
+ function stripBase(pathname: string, basePath: string): string {
5
+ if (pathname.startsWith(basePath)) {
6
+ const rest = pathname.slice(basePath.length);
7
+ return rest === '' || rest === '/' ? '/' : rest.startsWith('/') ? rest : `/${rest}`;
8
+ }
9
+ return '/';
10
+ }
11
+
12
+ describe('stripBase', () => {
13
+ it('strips the base path and returns the remainder with a leading slash', () => {
14
+ expect(stripBase('/admin/dashboard', '/admin')).toBe('/dashboard');
15
+ expect(stripBase('/admin/collections/posts', '/admin')).toBe('/collections/posts');
16
+ });
17
+
18
+ it('returns root when the pathname is exactly the base or base plus trailing slash', () => {
19
+ expect(stripBase('/admin', '/admin')).toBe('/');
20
+ expect(stripBase('/admin/', '/admin')).toBe('/');
21
+ });
22
+
23
+ it('returns root when the pathname does not start with the base path', () => {
24
+ expect(stripBase('/public/page', '/admin')).toBe('/');
25
+ });
26
+
27
+ it('normalizes remainder that lacks a leading slash', () => {
28
+ expect(stripBase('/adminfoo', '/admin')).toBe('/foo');
29
+ });
30
+ });
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import { ChevronRight, Home } from 'lucide-react';
4
+
5
+ const LABEL_MAP: Record<string, string> = {
6
+ posts: 'Posts',
7
+ pages: 'Pages',
8
+ media: 'Media Library',
9
+ forms: 'Forms',
10
+ seo: 'SEO',
11
+ redirects: 'Redirects',
12
+ canonicals: 'Canonicalization',
13
+ links: 'Link Health',
14
+ users: 'Users',
15
+ settings: 'Settings',
16
+ collections: 'Collections',
17
+ submissions: 'Submissions',
18
+ new: 'New',
19
+ edit: 'Edit',
20
+ };
21
+
22
+ function labelFor(segment: string): string {
23
+ return LABEL_MAP[segment] ?? segment.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
24
+ }
25
+
26
+ function isId(segment: string): boolean {
27
+ return /^\d+$/.test(segment) || /^[0-9a-f]{8,}$/i.test(segment);
28
+ }
29
+
30
+ export interface BreadcrumbsProps {
31
+ currentPath: string;
32
+ onNavigate: (path: string) => void;
33
+ }
34
+
35
+ export function Breadcrumbs({ currentPath, onNavigate }: BreadcrumbsProps) {
36
+ const segments = currentPath.split('/').filter(Boolean);
37
+
38
+ if (segments.length === 0) return null;
39
+
40
+ const crumbs: { label: string; path: string }[] = [];
41
+ let accumulated = '';
42
+
43
+ for (let i = 0; i < segments.length; i++) {
44
+ const seg = segments[i]!;
45
+ accumulated += `/${seg}`;
46
+
47
+ let label: string;
48
+ if (isId(seg)) {
49
+ const parentLabel = i > 0 ? labelFor(segments[i - 1]!) : '';
50
+ label = parentLabel
51
+ ? `Edit ${parentLabel.replace(/s$/, '')}`
52
+ : `#${seg}`;
53
+ } else {
54
+ label = labelFor(seg);
55
+ }
56
+
57
+ crumbs.push({ label, path: accumulated });
58
+ }
59
+
60
+ return (
61
+ <nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm py-2.5 px-4 bg-white border-b border-gray-200 overflow-x-auto">
62
+ <button
63
+ type="button"
64
+ onClick={() => onNavigate('/')}
65
+ className="flex items-center gap-1 text-gray-500 hover:text-blue-600 transition-colors shrink-0"
66
+ >
67
+ <Home className="w-3.5 h-3.5" />
68
+ <span className="hidden sm:inline">Dashboard</span>
69
+ </button>
70
+
71
+ {crumbs.map((crumb, i) => {
72
+ const isLast = i === crumbs.length - 1;
73
+ return (
74
+ <span key={crumb.path} className="flex items-center gap-1 shrink-0">
75
+ <ChevronRight className="w-3.5 h-3.5 text-gray-400" />
76
+ {isLast ? (
77
+ <span className="font-medium text-gray-900">{crumb.label}</span>
78
+ ) : (
79
+ <button
80
+ type="button"
81
+ onClick={() => onNavigate(crumb.path)}
82
+ className="text-gray-500 hover:text-blue-600 transition-colors"
83
+ >
84
+ {crumb.label}
85
+ </button>
86
+ )}
87
+ </span>
88
+ );
89
+ })}
90
+ </nav>
91
+ );
92
+ }