@actuate-media/cms-admin 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +17 -11
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/actuate-admin.css +2 -0
  6. package/dist/components/TipTapEditor.js +78 -78
  7. package/dist/lib/useApiData.d.ts +8 -1
  8. package/dist/lib/useApiData.d.ts.map +1 -1
  9. package/dist/lib/useApiData.js +39 -7
  10. package/dist/lib/useApiData.js.map +1 -1
  11. package/dist/views/Dashboard.d.ts +2 -1
  12. package/dist/views/Dashboard.d.ts.map +1 -1
  13. package/dist/views/Dashboard.js +52 -7
  14. package/dist/views/Dashboard.js.map +1 -1
  15. package/package.json +10 -5
  16. package/src/AdminRoot.tsx +312 -0
  17. package/src/__tests__/lib/search.test.ts +138 -0
  18. package/src/__tests__/lib/utils.test.ts +19 -0
  19. package/src/__tests__/router/match-route.test.ts +47 -0
  20. package/src/__tests__/router/strip-base.test.ts +30 -0
  21. package/src/components/Breadcrumbs.tsx +92 -0
  22. package/src/components/CommandPalette.tsx +384 -0
  23. package/src/components/ErrorBoundary.tsx +52 -0
  24. package/src/components/FocalPointPicker.tsx +54 -0
  25. package/src/components/FolderTree.tsx +427 -0
  26. package/src/components/LivePreview.tsx +136 -0
  27. package/src/components/LocaleProvider.tsx +51 -0
  28. package/src/components/LocaleSwitcher.tsx +51 -0
  29. package/src/components/MediaPickerModal.tsx +183 -0
  30. package/src/components/PresenceIndicator.tsx +71 -0
  31. package/src/components/SEOPanel.tsx +767 -0
  32. package/src/components/ThemeProvider.tsx +98 -0
  33. package/src/components/TipTapEditor.tsx +469 -0
  34. package/src/components/VersionHistory.tsx +167 -0
  35. package/src/components/ui/Avatar.tsx +42 -0
  36. package/src/components/ui/Badge.tsx +25 -0
  37. package/src/components/ui/Button.tsx +52 -0
  38. package/src/components/ui/CommandPalette.tsx +119 -0
  39. package/src/components/ui/ConfirmDialog.tsx +52 -0
  40. package/src/components/ui/DataTable.tsx +194 -0
  41. package/src/components/ui/EmptyState.tsx +29 -0
  42. package/src/components/ui/Modal.tsx +48 -0
  43. package/src/components/ui/Pagination.tsx +79 -0
  44. package/src/components/ui/SearchInput.tsx +44 -0
  45. package/src/components/ui/Skeleton.tsx +48 -0
  46. package/src/components/ui/Toast.tsx +66 -0
  47. package/src/components/ui/index.ts +24 -0
  48. package/src/fields/ArrayField.tsx +92 -0
  49. package/src/fields/BlockBuilderField.tsx +421 -0
  50. package/src/fields/DateField.tsx +41 -0
  51. package/src/fields/FieldRenderer.tsx +84 -0
  52. package/src/fields/GroupField.tsx +41 -0
  53. package/src/fields/MediaField.tsx +48 -0
  54. package/src/fields/NavBuilderField.tsx +78 -0
  55. package/src/fields/NumberField.tsx +45 -0
  56. package/src/fields/RelationshipField.tsx +245 -0
  57. package/src/fields/RichTextField.tsx +26 -0
  58. package/src/fields/SelectField.tsx +117 -0
  59. package/src/fields/SlugField.tsx +65 -0
  60. package/src/fields/TextField.tsx +48 -0
  61. package/src/fields/ToggleField.tsx +36 -0
  62. package/src/fields/block-types.ts +95 -0
  63. package/src/fields/index.ts +17 -0
  64. package/src/hooks/useContentLock.ts +52 -0
  65. package/src/hooks/useDebounce.ts +14 -0
  66. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  67. package/src/index.ts +55 -0
  68. package/src/layout/Header.tsx +135 -0
  69. package/src/layout/Layout.tsx +77 -0
  70. package/src/layout/Sidebar.tsx +216 -0
  71. package/src/lib/api.ts +67 -0
  72. package/src/lib/search.ts +59 -0
  73. package/src/lib/useApiData.ts +95 -0
  74. package/src/lib/utils.ts +6 -0
  75. package/src/router/index.ts +81 -0
  76. package/src/styles/build-input.css +11 -0
  77. package/src/styles/tailwind.css +11 -6
  78. package/src/styles/theme.css +182 -181
  79. package/src/views/CollectionList.tsx +270 -0
  80. package/src/views/Dashboard.tsx +300 -0
  81. package/src/views/DocumentEdit.tsx +377 -0
  82. package/src/views/FormEditor.tsx +533 -0
  83. package/src/views/FormSubmissions.tsx +316 -0
  84. package/src/views/Forms.tsx +106 -0
  85. package/src/views/Login.tsx +322 -0
  86. package/src/views/MediaBrowser.tsx +774 -0
  87. package/src/views/PageEditor.tsx +192 -0
  88. package/src/views/Pages.tsx +354 -0
  89. package/src/views/PostEditor.tsx +251 -0
  90. package/src/views/Posts.tsx +243 -0
  91. package/src/views/Redirects.tsx +293 -0
  92. package/src/views/SEO.tsx +458 -0
  93. package/src/views/Settings.tsx +811 -0
  94. package/src/views/SetupWizard.tsx +207 -0
  95. package/src/views/Users.tsx +282 -0
@@ -1,181 +1,182 @@
1
- @custom-variant dark (&:is(.dark *));
2
-
3
- .actuate-admin {
4
- --font-size: 16px;
5
- --background: #ffffff;
6
- --foreground: oklch(0.145 0 0);
7
- --card: #ffffff;
8
- --card-foreground: oklch(0.145 0 0);
9
- --popover: oklch(1 0 0);
10
- --popover-foreground: oklch(0.145 0 0);
11
- --primary: #030213;
12
- --primary-foreground: oklch(1 0 0);
13
- --secondary: oklch(0.95 0.0058 264.53);
14
- --secondary-foreground: #030213;
15
- --muted: #ececf0;
16
- --muted-foreground: #717182;
17
- --accent: #e9ebef;
18
- --accent-foreground: #030213;
19
- --destructive: #d4183d;
20
- --destructive-foreground: #ffffff;
21
- --border: rgba(0, 0, 0, 0.1);
22
- --input: transparent;
23
- --input-background: #f3f3f5;
24
- --switch-background: #cbced4;
25
- --font-weight-medium: 500;
26
- --font-weight-normal: 400;
27
- --ring: oklch(0.708 0 0);
28
- --chart-1: oklch(0.646 0.222 41.116);
29
- --chart-2: oklch(0.6 0.118 184.704);
30
- --chart-3: oklch(0.398 0.07 227.392);
31
- --chart-4: oklch(0.828 0.189 84.429);
32
- --chart-5: oklch(0.769 0.188 70.08);
33
- --radius: 0.625rem;
34
- --sidebar: oklch(0.985 0 0);
35
- --sidebar-foreground: oklch(0.145 0 0);
36
- --sidebar-primary: #030213;
37
- --sidebar-primary-foreground: oklch(0.985 0 0);
38
- --sidebar-accent: oklch(0.97 0 0);
39
- --sidebar-accent-foreground: oklch(0.205 0 0);
40
- --sidebar-border: oklch(0.922 0 0);
41
- --sidebar-ring: oklch(0.708 0 0);
42
- }
43
-
44
- .dark .actuate-admin,
45
- .actuate-admin.dark {
46
- --background: oklch(0.145 0 0);
47
- --foreground: oklch(0.985 0 0);
48
- --card: oklch(0.185 0 0);
49
- --card-foreground: oklch(0.985 0 0);
50
- --popover: oklch(0.185 0 0);
51
- --popover-foreground: oklch(0.985 0 0);
52
- --primary: oklch(0.985 0 0);
53
- --primary-foreground: oklch(0.205 0 0);
54
- --secondary: oklch(0.269 0 0);
55
- --secondary-foreground: oklch(0.985 0 0);
56
- --muted: oklch(0.269 0 0);
57
- --muted-foreground: oklch(0.708 0 0);
58
- --accent: oklch(0.269 0 0);
59
- --accent-foreground: oklch(0.985 0 0);
60
- --destructive: oklch(0.396 0.141 25.723);
61
- --destructive-foreground: oklch(0.637 0.237 25.331);
62
- --border: oklch(0.269 0 0);
63
- --input: oklch(0.269 0 0);
64
- --input-background: oklch(0.205 0 0);
65
- --switch-background: oklch(0.35 0 0);
66
- --ring: oklch(0.439 0 0);
67
- --font-weight-medium: 500;
68
- --font-weight-normal: 400;
69
- --chart-1: oklch(0.488 0.243 264.376);
70
- --chart-2: oklch(0.696 0.17 162.48);
71
- --chart-3: oklch(0.769 0.188 70.08);
72
- --chart-4: oklch(0.627 0.265 303.9);
73
- --chart-5: oklch(0.645 0.246 16.439);
74
- --sidebar: oklch(0.205 0 0);
75
- --sidebar-foreground: oklch(0.985 0 0);
76
- --sidebar-primary: oklch(0.488 0.243 264.376);
77
- --sidebar-primary-foreground: oklch(0.985 0 0);
78
- --sidebar-accent: oklch(0.269 0 0);
79
- --sidebar-accent-foreground: oklch(0.985 0 0);
80
- --sidebar-border: oklch(0.269 0 0);
81
- --sidebar-ring: oklch(0.439 0 0);
82
- }
83
-
84
- @theme inline {
85
- --color-background: var(--background);
86
- --color-foreground: var(--foreground);
87
- --color-card: var(--card);
88
- --color-card-foreground: var(--card-foreground);
89
- --color-popover: var(--popover);
90
- --color-popover-foreground: var(--popover-foreground);
91
- --color-primary: var(--primary);
92
- --color-primary-foreground: var(--primary-foreground);
93
- --color-secondary: var(--secondary);
94
- --color-secondary-foreground: var(--secondary-foreground);
95
- --color-muted: var(--muted);
96
- --color-muted-foreground: var(--muted-foreground);
97
- --color-accent: var(--accent);
98
- --color-accent-foreground: var(--accent-foreground);
99
- --color-destructive: var(--destructive);
100
- --color-destructive-foreground: var(--destructive-foreground);
101
- --color-border: var(--border);
102
- --color-input: var(--input);
103
- --color-input-background: var(--input-background);
104
- --color-switch-background: var(--switch-background);
105
- --color-ring: var(--ring);
106
- --color-chart-1: var(--chart-1);
107
- --color-chart-2: var(--chart-2);
108
- --color-chart-3: var(--chart-3);
109
- --color-chart-4: var(--chart-4);
110
- --color-chart-5: var(--chart-5);
111
- --radius-sm: calc(var(--radius) - 4px);
112
- --radius-md: calc(var(--radius) - 2px);
113
- --radius-lg: var(--radius);
114
- --radius-xl: calc(var(--radius) + 4px);
115
- --color-sidebar: var(--sidebar);
116
- --color-sidebar-foreground: var(--sidebar-foreground);
117
- --color-sidebar-primary: var(--sidebar-primary);
118
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
119
- --color-sidebar-accent: var(--sidebar-accent);
120
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
121
- --color-sidebar-border: var(--sidebar-border);
122
- --color-sidebar-ring: var(--sidebar-ring);
123
- }
124
-
125
- @layer base {
126
- .actuate-admin * {
127
- @apply border-border outline-ring/50;
128
- }
129
-
130
- .actuate-admin {
131
- @apply bg-background text-foreground;
132
- font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
133
- font-size: var(--font-size);
134
- line-height: 1.5;
135
- isolation: isolate;
136
- -webkit-font-smoothing: antialiased;
137
- -moz-osx-font-smoothing: grayscale;
138
- }
139
-
140
- .actuate-admin h1 {
141
- font-size: var(--text-2xl);
142
- font-weight: var(--font-weight-medium);
143
- line-height: 1.5;
144
- }
145
-
146
- .actuate-admin h2 {
147
- font-size: var(--text-xl);
148
- font-weight: var(--font-weight-medium);
149
- line-height: 1.5;
150
- }
151
-
152
- .actuate-admin h3 {
153
- font-size: var(--text-lg);
154
- font-weight: var(--font-weight-medium);
155
- line-height: 1.5;
156
- }
157
-
158
- .actuate-admin h4 {
159
- font-size: var(--text-base);
160
- font-weight: var(--font-weight-medium);
161
- line-height: 1.5;
162
- }
163
-
164
- .actuate-admin label {
165
- font-size: var(--text-base);
166
- font-weight: var(--font-weight-medium);
167
- line-height: 1.5;
168
- }
169
-
170
- .actuate-admin button {
171
- font-size: var(--text-base);
172
- font-weight: var(--font-weight-medium);
173
- line-height: 1.5;
174
- }
175
-
176
- .actuate-admin input {
177
- font-size: var(--text-base);
178
- font-weight: var(--font-weight-normal);
179
- line-height: 1.5;
180
- }
181
- }
1
+ @custom-variant dark (&:is(.dark *));
2
+
3
+ .actuate-admin {
4
+ --font-size: 16px;
5
+ --background: #ffffff;
6
+ --foreground: oklch(0.145 0 0);
7
+ --card: #ffffff;
8
+ --card-foreground: oklch(0.145 0 0);
9
+ --popover: oklch(1 0 0);
10
+ --popover-foreground: oklch(0.145 0 0);
11
+ --primary: #030213;
12
+ --primary-foreground: oklch(1 0 0);
13
+ --secondary: oklch(0.95 0.0058 264.53);
14
+ --secondary-foreground: #030213;
15
+ --muted: #ececf0;
16
+ --muted-foreground: #717182;
17
+ --accent: #e9ebef;
18
+ --accent-foreground: #030213;
19
+ --destructive: #d4183d;
20
+ --destructive-foreground: #ffffff;
21
+ --border: rgba(0, 0, 0, 0.1);
22
+ --input: transparent;
23
+ --input-background: #f3f3f5;
24
+ --switch-background: #cbced4;
25
+ --font-weight-medium: 500;
26
+ --font-weight-normal: 400;
27
+ --ring: oklch(0.708 0 0);
28
+ --chart-1: oklch(0.646 0.222 41.116);
29
+ --chart-2: oklch(0.6 0.118 184.704);
30
+ --chart-3: oklch(0.398 0.07 227.392);
31
+ --chart-4: oklch(0.828 0.189 84.429);
32
+ --chart-5: oklch(0.769 0.188 70.08);
33
+ --radius: 0.625rem;
34
+ --sidebar: oklch(0.985 0 0);
35
+ --sidebar-foreground: oklch(0.145 0 0);
36
+ --sidebar-primary: #030213;
37
+ --sidebar-primary-foreground: oklch(0.985 0 0);
38
+ --sidebar-accent: oklch(0.97 0 0);
39
+ --sidebar-accent-foreground: oklch(0.205 0 0);
40
+ --sidebar-border: oklch(0.922 0 0);
41
+ --sidebar-ring: oklch(0.708 0 0);
42
+ }
43
+
44
+ .dark .actuate-admin,
45
+ .actuate-admin.dark {
46
+ --background: oklch(0.145 0 0);
47
+ --foreground: oklch(0.985 0 0);
48
+ --card: oklch(0.185 0 0);
49
+ --card-foreground: oklch(0.985 0 0);
50
+ --popover: oklch(0.185 0 0);
51
+ --popover-foreground: oklch(0.985 0 0);
52
+ --primary: oklch(0.985 0 0);
53
+ --primary-foreground: oklch(0.205 0 0);
54
+ --secondary: oklch(0.269 0 0);
55
+ --secondary-foreground: oklch(0.985 0 0);
56
+ --muted: oklch(0.269 0 0);
57
+ --muted-foreground: oklch(0.708 0 0);
58
+ --accent: oklch(0.269 0 0);
59
+ --accent-foreground: oklch(0.985 0 0);
60
+ --destructive: oklch(0.396 0.141 25.723);
61
+ --destructive-foreground: oklch(0.637 0.237 25.331);
62
+ --border: oklch(0.269 0 0);
63
+ --input: oklch(0.269 0 0);
64
+ --input-background: oklch(0.205 0 0);
65
+ --switch-background: oklch(0.35 0 0);
66
+ --ring: oklch(0.439 0 0);
67
+ --font-weight-medium: 500;
68
+ --font-weight-normal: 400;
69
+ --chart-1: oklch(0.488 0.243 264.376);
70
+ --chart-2: oklch(0.696 0.17 162.48);
71
+ --chart-3: oklch(0.769 0.188 70.08);
72
+ --chart-4: oklch(0.627 0.265 303.9);
73
+ --chart-5: oklch(0.645 0.246 16.439);
74
+ --sidebar: oklch(0.205 0 0);
75
+ --sidebar-foreground: oklch(0.985 0 0);
76
+ --sidebar-primary: oklch(0.488 0.243 264.376);
77
+ --sidebar-primary-foreground: oklch(0.985 0 0);
78
+ --sidebar-accent: oklch(0.269 0 0);
79
+ --sidebar-accent-foreground: oklch(0.985 0 0);
80
+ --sidebar-border: oklch(0.269 0 0);
81
+ --sidebar-ring: oklch(0.439 0 0);
82
+ }
83
+
84
+ @theme inline {
85
+ --color-background: var(--background);
86
+ --color-foreground: var(--foreground);
87
+ --color-card: var(--card);
88
+ --color-card-foreground: var(--card-foreground);
89
+ --color-popover: var(--popover);
90
+ --color-popover-foreground: var(--popover-foreground);
91
+ --color-primary: var(--primary);
92
+ --color-primary-foreground: var(--primary-foreground);
93
+ --color-secondary: var(--secondary);
94
+ --color-secondary-foreground: var(--secondary-foreground);
95
+ --color-muted: var(--muted);
96
+ --color-muted-foreground: var(--muted-foreground);
97
+ --color-accent: var(--accent);
98
+ --color-accent-foreground: var(--accent-foreground);
99
+ --color-destructive: var(--destructive);
100
+ --color-destructive-foreground: var(--destructive-foreground);
101
+ --color-border: var(--border);
102
+ --color-input: var(--input);
103
+ --color-input-background: var(--input-background);
104
+ --color-switch-background: var(--switch-background);
105
+ --color-ring: var(--ring);
106
+ --color-chart-1: var(--chart-1);
107
+ --color-chart-2: var(--chart-2);
108
+ --color-chart-3: var(--chart-3);
109
+ --color-chart-4: var(--chart-4);
110
+ --color-chart-5: var(--chart-5);
111
+ --radius-sm: calc(var(--radius) - 4px);
112
+ --radius-md: calc(var(--radius) - 2px);
113
+ --radius-lg: var(--radius);
114
+ --radius-xl: calc(var(--radius) + 4px);
115
+ --color-sidebar: var(--sidebar);
116
+ --color-sidebar-foreground: var(--sidebar-foreground);
117
+ --color-sidebar-primary: var(--sidebar-primary);
118
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
119
+ --color-sidebar-accent: var(--sidebar-accent);
120
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
121
+ --color-sidebar-border: var(--sidebar-border);
122
+ --color-sidebar-ring: var(--sidebar-ring);
123
+ }
124
+
125
+ @layer base {
126
+ .actuate-admin * {
127
+ @apply border-border outline-ring/50;
128
+ }
129
+
130
+ .actuate-admin {
131
+ @apply bg-background text-foreground;
132
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
133
+ font-size: var(--font-size);
134
+ line-height: 1.5;
135
+ isolation: isolate;
136
+ contain: layout style;
137
+ -webkit-font-smoothing: antialiased;
138
+ -moz-osx-font-smoothing: grayscale;
139
+ }
140
+
141
+ .actuate-admin h1 {
142
+ font-size: var(--text-2xl);
143
+ font-weight: var(--font-weight-medium);
144
+ line-height: 1.5;
145
+ }
146
+
147
+ .actuate-admin h2 {
148
+ font-size: var(--text-xl);
149
+ font-weight: var(--font-weight-medium);
150
+ line-height: 1.5;
151
+ }
152
+
153
+ .actuate-admin h3 {
154
+ font-size: var(--text-lg);
155
+ font-weight: var(--font-weight-medium);
156
+ line-height: 1.5;
157
+ }
158
+
159
+ .actuate-admin h4 {
160
+ font-size: var(--text-base);
161
+ font-weight: var(--font-weight-medium);
162
+ line-height: 1.5;
163
+ }
164
+
165
+ .actuate-admin label {
166
+ font-size: var(--text-base);
167
+ font-weight: var(--font-weight-medium);
168
+ line-height: 1.5;
169
+ }
170
+
171
+ .actuate-admin button {
172
+ font-size: var(--text-base);
173
+ font-weight: var(--font-weight-medium);
174
+ line-height: 1.5;
175
+ }
176
+
177
+ .actuate-admin input {
178
+ font-size: var(--text-base);
179
+ font-weight: var(--font-weight-normal);
180
+ line-height: 1.5;
181
+ }
182
+ }
@@ -0,0 +1,270 @@
1
+ 'use client';
2
+
3
+ import { Search, Plus, Trash2, ChevronLeft, ChevronRight, Loader2, FileText, MoreHorizontal } from 'lucide-react';
4
+ import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
5
+ import { toast } from 'sonner';
6
+ import { cmsApi } from '../lib/api.js';
7
+ import { useApiData } from '../lib/useApiData.js';
8
+
9
+ export interface CollectionListProps {
10
+ collectionSlug: string;
11
+ config: any;
12
+ onNavigate: (path: string) => void;
13
+ }
14
+
15
+ type SortField = 'title' | 'status' | 'updatedAt';
16
+ type SortOrder = 'asc' | 'desc';
17
+
18
+ function resolveLabel(config: any, slug: string): { singular: string; plural: string } {
19
+ const fallback = { singular: slug, plural: slug };
20
+ if (!config?.collections) return fallback;
21
+ const list = Array.isArray(config.collections)
22
+ ? config.collections
23
+ : Object.values(config.collections);
24
+ const match = (list as any[]).find((c: any) => c.slug === slug);
25
+ return match?.labels ?? fallback;
26
+ }
27
+
28
+ function statusColor(status: string): string {
29
+ switch (status?.toUpperCase()) {
30
+ case 'PUBLISHED': return 'var(--actuate-success-bg, #dcfce7)';
31
+ case 'DRAFT': return 'var(--actuate-muted-bg, #f3f4f6)';
32
+ case 'ARCHIVED': return 'var(--actuate-warning-bg, #fef9c3)';
33
+ case 'SCHEDULED': return 'var(--actuate-info-bg, #dbeafe)';
34
+ default: return 'var(--actuate-muted-bg, #f3f4f6)';
35
+ }
36
+ }
37
+
38
+ function statusText(status: string): string {
39
+ switch (status?.toUpperCase()) {
40
+ case 'PUBLISHED': return 'var(--actuate-success-text, #166534)';
41
+ case 'DRAFT': return 'var(--actuate-muted-text, #374151)';
42
+ case 'ARCHIVED': return 'var(--actuate-warning-text, #854d0e)';
43
+ case 'SCHEDULED': return 'var(--actuate-info-text, #1e40af)';
44
+ default: return 'var(--actuate-muted-text, #374151)';
45
+ }
46
+ }
47
+
48
+ function formatDate(d: string | undefined): string {
49
+ if (!d) return '—';
50
+ return new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
51
+ }
52
+
53
+ export function CollectionList({ collectionSlug, config, onNavigate }: CollectionListProps) {
54
+ const labels = useMemo(() => resolveLabel(config, collectionSlug), [config, collectionSlug]);
55
+
56
+ const [page, setPage] = useState(1);
57
+ const [search, setSearch] = useState('');
58
+ const [debouncedSearch, setDebouncedSearch] = useState('');
59
+ const [sort, setSort] = useState<SortField | null>(null);
60
+ const [order, setOrder] = useState<SortOrder>('asc');
61
+ const [selected, setSelected] = useState<Set<string>>(new Set());
62
+ const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
63
+
64
+ useEffect(() => {
65
+ clearTimeout(searchTimer.current);
66
+ searchTimer.current = setTimeout(() => {
67
+ setDebouncedSearch(search);
68
+ setPage(1);
69
+ }, 300);
70
+ return () => clearTimeout(searchTimer.current);
71
+ }, [search]);
72
+
73
+ const endpoint = useMemo(() => {
74
+ let url = `/collections/${collectionSlug}?page=${page}&pageSize=25`;
75
+ if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`;
76
+ if (sort) url += `&sort=${sort}&order=${order}`;
77
+ return url;
78
+ }, [collectionSlug, page, debouncedSearch, sort, order]);
79
+
80
+ const { data, loading, error, refetch } = useApiData<{ docs: any[]; total: number }>(endpoint);
81
+ const docs = data?.docs ?? [];
82
+ const total = data?.total ?? 0;
83
+ const totalPages = Math.max(1, Math.ceil(total / 25));
84
+
85
+ useEffect(() => { setPage(1); setSelected(new Set()); setSearch(''); }, [collectionSlug]);
86
+
87
+ const toggleSort = useCallback((field: SortField) => {
88
+ setSort(prev => {
89
+ if (prev === field) {
90
+ setOrder(o => o === 'asc' ? 'desc' : 'asc');
91
+ return field;
92
+ }
93
+ setOrder('asc');
94
+ return field;
95
+ });
96
+ }, []);
97
+
98
+ const toggleSelect = (id: string) =>
99
+ setSelected(prev => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
100
+
101
+ const toggleAll = (checked: boolean) =>
102
+ setSelected(checked ? new Set(docs.map((d: any) => String(d.id))) : new Set());
103
+
104
+ const bulkAction = async (action: 'delete' | 'publish' | 'unpublish') => {
105
+ const ids = [...selected];
106
+ for (const id of ids) {
107
+ if (action === 'delete') {
108
+ await cmsApi(`/collections/${collectionSlug}/${id}`, { method: 'DELETE' });
109
+ } else {
110
+ await cmsApi(`/collections/${collectionSlug}/${id}`, {
111
+ method: 'PUT',
112
+ body: JSON.stringify({ status: action === 'publish' ? 'PUBLISHED' : 'DRAFT' }),
113
+ });
114
+ }
115
+ }
116
+ const verb = action === 'delete' ? 'deleted' : action === 'publish' ? 'published' : 'unpublished';
117
+ toast.success(`${ids.length} ${ids.length === 1 ? labels.singular : labels.plural} ${verb}`);
118
+ setSelected(new Set());
119
+ refetch();
120
+ };
121
+
122
+ const SortCol = ({ field, children }: { field: SortField; children: string }) => (
123
+ <button type="button" onClick={() => toggleSort(field)}
124
+ className="flex items-center gap-1 text-xs font-medium hover:opacity-80 transition-opacity"
125
+ style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}>
126
+ {children}
127
+ {sort === field && <span>{order === 'asc' ? '↑' : '↓'}</span>}
128
+ </button>
129
+ );
130
+
131
+ if (loading && docs.length === 0) {
132
+ return (
133
+ <div className="p-4 flex items-center justify-center h-64">
134
+ <Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--actuate-primary, #2563eb)' }} />
135
+ </div>
136
+ );
137
+ }
138
+
139
+ return (
140
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 h-full flex flex-col">
141
+ {/* Header */}
142
+ <div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
143
+ <div>
144
+ <h1 className="text-xl sm:text-2xl font-semibold" style={{ color: 'var(--actuate-text, #111827)' }}>
145
+ {labels.plural}
146
+ </h1>
147
+ <p className="text-sm" style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}>{total} total</p>
148
+ </div>
149
+ <button onClick={() => onNavigate(`/${collectionSlug}/new`)}
150
+ className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors"
151
+ style={{ background: 'var(--actuate-primary, #2563eb)' }}>
152
+ <Plus className="w-4 h-4" /> New {labels.singular}
153
+ </button>
154
+ </div>
155
+
156
+ {/* Search */}
157
+ <div className="relative mb-4">
158
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--actuate-text-muted, #9ca3af)' }} />
159
+ <input type="text" placeholder={`Search ${labels.plural.toLowerCase()}...`} value={search}
160
+ onChange={e => setSearch(e.target.value)}
161
+ className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border focus:outline-none focus:ring-2"
162
+ style={{ borderColor: 'var(--actuate-border, #d1d5db)', color: 'var(--actuate-text, #111827)' }} />
163
+ </div>
164
+
165
+ {/* Bulk actions */}
166
+ {selected.size > 0 && (
167
+ <div className="rounded-lg p-3 mb-4 flex flex-wrap items-center justify-between gap-2"
168
+ style={{ background: 'var(--actuate-info-bg, #eff6ff)', borderColor: 'var(--actuate-info-border, #bfdbfe)', borderWidth: 1, borderStyle: 'solid' }}>
169
+ <span className="text-sm" style={{ color: 'var(--actuate-info-text, #1e40af)' }}>
170
+ {selected.size} selected
171
+ </span>
172
+ <div className="flex gap-2">
173
+ <button onClick={() => bulkAction('publish')} className="px-3 py-1.5 text-sm text-white rounded-lg" style={{ background: 'var(--actuate-success, #16a34a)' }}>Publish</button>
174
+ <button onClick={() => bulkAction('unpublish')} className="px-3 py-1.5 text-sm text-white rounded-lg" style={{ background: 'var(--actuate-warning, #ca8a04)' }}>Unpublish</button>
175
+ <button onClick={() => bulkAction('delete')} className="px-3 py-1.5 text-sm text-white rounded-lg" style={{ background: 'var(--actuate-danger, #dc2626)' }}>
176
+ <Trash2 className="w-3.5 h-3.5 inline -mt-0.5 mr-1" />Delete
177
+ </button>
178
+ </div>
179
+ </div>
180
+ )}
181
+
182
+ {error && (
183
+ <div className="rounded-lg p-3 mb-4 text-sm" style={{ background: 'var(--actuate-danger-bg, #fef2f2)', color: 'var(--actuate-danger-text, #991b1b)' }}>
184
+ {error} — <button onClick={refetch} className="underline">retry</button>
185
+ </div>
186
+ )}
187
+
188
+ {/* Table */}
189
+ {docs.length === 0 && !loading ? (
190
+ <div className="flex-1 flex flex-col items-center justify-center rounded-lg border p-8"
191
+ style={{ borderColor: 'var(--actuate-border, #d1d5db)', color: 'var(--actuate-text-secondary, #6b7280)' }}>
192
+ <FileText className="w-10 h-10 mb-3 opacity-40" />
193
+ <p className="text-sm mb-3">No {labels.plural.toLowerCase()} found</p>
194
+ <button onClick={() => onNavigate(`/${collectionSlug}/new`)}
195
+ className="px-4 py-2 text-sm text-white rounded-lg" style={{ background: 'var(--actuate-primary, #2563eb)' }}>
196
+ Create {labels.singular}
197
+ </button>
198
+ </div>
199
+ ) : (
200
+ <div className="flex-1 rounded-lg border overflow-auto" style={{ borderColor: 'var(--actuate-border, #d1d5db)' }}>
201
+ <table className="w-full text-sm">
202
+ <thead className="sticky top-0" style={{ background: 'var(--actuate-surface-alt, #f9fafb)', borderBottom: '1px solid var(--actuate-border, #d1d5db)' }}>
203
+ <tr>
204
+ <th className="w-10 px-3 py-2 text-left">
205
+ <input type="checkbox" checked={selected.size === docs.length && docs.length > 0}
206
+ onChange={e => toggleAll(e.target.checked)} className="rounded" />
207
+ </th>
208
+ <th className="px-3 py-2 text-left"><SortCol field="title">Title</SortCol></th>
209
+ <th className="px-3 py-2 text-left"><SortCol field="status">Status</SortCol></th>
210
+ <th className="px-3 py-2 text-left"><SortCol field="updatedAt">Updated</SortCol></th>
211
+ <th className="px-3 py-2 text-left text-xs font-medium" style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}>Actions</th>
212
+ </tr>
213
+ </thead>
214
+ <tbody>
215
+ {docs.map((doc: any) => (
216
+ <tr key={doc.id} className="transition-colors hover:opacity-95"
217
+ style={{ borderBottom: '1px solid var(--actuate-border, #e5e7eb)' }}>
218
+ <td className="px-3 py-2">
219
+ <input type="checkbox" checked={selected.has(String(doc.id))}
220
+ onChange={() => toggleSelect(String(doc.id))} className="rounded" />
221
+ </td>
222
+ <td className="px-3 py-2">
223
+ <button type="button" onClick={() => onNavigate(`/${collectionSlug}/${doc.id}`)}
224
+ className="font-medium text-left hover:underline" style={{ color: 'var(--actuate-text, #111827)' }}>
225
+ {doc.title || doc.name || `#${doc.id}`}
226
+ </button>
227
+ </td>
228
+ <td className="px-3 py-2">
229
+ <span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
230
+ style={{ background: statusColor(doc.status), color: statusText(doc.status) }}>
231
+ {doc.status ?? 'DRAFT'}
232
+ </span>
233
+ </td>
234
+ <td className="px-3 py-2" style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}>
235
+ {formatDate(doc.updatedAt)}
236
+ </td>
237
+ <td className="px-3 py-2">
238
+ <button type="button" onClick={() => onNavigate(`/${collectionSlug}/${doc.id}`)}
239
+ className="p-1.5 rounded hover:opacity-75 transition-opacity" title="Edit">
240
+ <MoreHorizontal className="w-4 h-4" style={{ color: 'var(--actuate-text-secondary, #6b7280)' }} />
241
+ </button>
242
+ </td>
243
+ </tr>
244
+ ))}
245
+ </tbody>
246
+ </table>
247
+ </div>
248
+ )}
249
+
250
+ {/* Pagination */}
251
+ {totalPages > 1 && (
252
+ <div className="flex items-center justify-between mt-4 text-sm" style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}>
253
+ <span>Page {page} of {totalPages}</span>
254
+ <div className="flex gap-2">
255
+ <button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
256
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg border disabled:opacity-40 transition-opacity"
257
+ style={{ borderColor: 'var(--actuate-border, #d1d5db)' }}>
258
+ <ChevronLeft className="w-4 h-4" /> Previous
259
+ </button>
260
+ <button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}
261
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg border disabled:opacity-40 transition-opacity"
262
+ style={{ borderColor: 'var(--actuate-border, #d1d5db)' }}>
263
+ Next <ChevronRight className="w-4 h-4" />
264
+ </button>
265
+ </div>
266
+ </div>
267
+ )}
268
+ </div>
269
+ );
270
+ }