@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,322 @@
1
+ 'use client';
2
+
3
+ import { useState, type FormEvent } from 'react';
4
+ import { Shield, Eye, EyeOff, AlertTriangle, Loader2 } from 'lucide-react';
5
+
6
+ export interface CaptchaConfig {
7
+ provider: 'recaptcha' | 'turnstile' | 'none';
8
+ siteKey: string | null;
9
+ }
10
+
11
+ export interface LoginProps {
12
+ onLogin: (email: string, password: string, captchaToken?: string) => Promise<{ success: boolean; error?: string }>;
13
+ onNavigate?: (path: string) => void;
14
+ oauthProviders?: string[];
15
+ captchaConfig?: CaptchaConfig;
16
+ }
17
+
18
+ const OAUTH_LABELS: Record<string, string> = {
19
+ google: 'Google',
20
+ github: 'GitHub',
21
+ microsoft: 'Microsoft',
22
+ };
23
+
24
+ const OAUTH_COLORS: Record<string, { border: string; bg: string; text: string; hover: string }> = {
25
+ google: {
26
+ border: 'border-gray-300',
27
+ bg: 'bg-white',
28
+ text: 'text-gray-700',
29
+ hover: 'hover:bg-gray-50',
30
+ },
31
+ github: {
32
+ border: 'border-gray-800',
33
+ bg: 'bg-gray-900',
34
+ text: 'text-white',
35
+ hover: 'hover:bg-gray-800',
36
+ },
37
+ microsoft: {
38
+ border: 'border-blue-500',
39
+ bg: 'bg-white',
40
+ text: 'text-gray-700',
41
+ hover: 'hover:bg-blue-50',
42
+ },
43
+ };
44
+
45
+ function GoogleIcon() {
46
+ return (
47
+ <svg className="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
48
+ <path
49
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
50
+ fill="#4285F4"
51
+ />
52
+ <path
53
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
54
+ fill="#34A853"
55
+ />
56
+ <path
57
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
58
+ fill="#FBBC05"
59
+ />
60
+ <path
61
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
62
+ fill="#EA4335"
63
+ />
64
+ </svg>
65
+ );
66
+ }
67
+
68
+ function MicrosoftIcon() {
69
+ return (
70
+ <svg className="w-5 h-5" viewBox="0 0 21 21" aria-hidden="true">
71
+ <rect x="1" y="1" width="9" height="9" fill="#F25022" />
72
+ <rect x="1" y="11" width="9" height="9" fill="#00A4EF" />
73
+ <rect x="11" y="1" width="9" height="9" fill="#7FBA00" />
74
+ <rect x="11" y="11" width="9" height="9" fill="#FFB900" />
75
+ </svg>
76
+ );
77
+ }
78
+
79
+ function OAuthIcon({ provider }: { provider: string }) {
80
+ switch (provider) {
81
+ case 'google':
82
+ return <GoogleIcon />;
83
+ case 'github':
84
+ return <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>;
85
+ case 'microsoft':
86
+ return <MicrosoftIcon />;
87
+ default:
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function useCaptchaScript(provider: string | undefined, siteKey: string | null | undefined) {
93
+ const [loaded, setLoaded] = useState(false);
94
+
95
+ useState(() => {
96
+ if (typeof window === 'undefined' || !siteKey) return;
97
+
98
+ if (provider === 'recaptcha') {
99
+ const existing = document.querySelector('script[src*="recaptcha"]');
100
+ if (existing) { setLoaded(true); return; }
101
+ const script = document.createElement('script');
102
+ script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
103
+ script.async = true;
104
+ script.onload = () => setLoaded(true);
105
+ document.head.appendChild(script);
106
+ } else if (provider === 'turnstile') {
107
+ const existing = document.querySelector('script[src*="turnstile"]');
108
+ if (existing) { setLoaded(true); return; }
109
+ const script = document.createElement('script');
110
+ script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
111
+ script.async = true;
112
+ script.onload = () => setLoaded(true);
113
+ document.head.appendChild(script);
114
+ }
115
+ });
116
+
117
+ return loaded;
118
+ }
119
+
120
+ async function getCaptchaToken(provider: string, siteKey: string, action: string): Promise<string> {
121
+ if (provider === 'recaptcha') {
122
+ const grecaptcha = (window as any).grecaptcha;
123
+ if (!grecaptcha) return '';
124
+ return new Promise((resolve) => {
125
+ grecaptcha.ready(() => {
126
+ grecaptcha.execute(siteKey, { action }).then(resolve).catch(() => resolve(''));
127
+ });
128
+ });
129
+ }
130
+
131
+ if (provider === 'turnstile') {
132
+ const turnstile = (window as any).turnstile;
133
+ if (!turnstile) return '';
134
+ return new Promise((resolve) => {
135
+ const container = document.createElement('div');
136
+ container.style.display = 'none';
137
+ document.body.appendChild(container);
138
+ turnstile.render(container, {
139
+ sitekey: siteKey,
140
+ action,
141
+ callback: (token: string) => {
142
+ container.remove();
143
+ resolve(token);
144
+ },
145
+ 'error-callback': () => {
146
+ container.remove();
147
+ resolve('');
148
+ },
149
+ });
150
+ });
151
+ }
152
+
153
+ return '';
154
+ }
155
+
156
+ export function Login({ onLogin, onNavigate, oauthProviders, captchaConfig }: LoginProps) {
157
+ const [email, setEmail] = useState('');
158
+ const [password, setPassword] = useState('');
159
+ const [showPassword, setShowPassword] = useState(false);
160
+ const [submitting, setSubmitting] = useState(false);
161
+ const [error, setError] = useState('');
162
+
163
+ useCaptchaScript(captchaConfig?.provider, captchaConfig?.siteKey);
164
+
165
+ const canSubmit = email.trim() && password && !submitting;
166
+
167
+ const handleSubmit = async (e: FormEvent) => {
168
+ e.preventDefault();
169
+ if (!canSubmit) return;
170
+
171
+ setError('');
172
+ setSubmitting(true);
173
+
174
+ try {
175
+ let captchaToken: string | undefined;
176
+ if (captchaConfig?.provider && captchaConfig.provider !== 'none' && captchaConfig.siteKey) {
177
+ captchaToken = await getCaptchaToken(captchaConfig.provider, captchaConfig.siteKey, 'login');
178
+ if (!captchaToken) {
179
+ setError('CAPTCHA verification failed. Please try again.');
180
+ setSubmitting(false);
181
+ return;
182
+ }
183
+ }
184
+
185
+ const result = await onLogin(email.trim(), password, captchaToken);
186
+
187
+ if (!result.success) {
188
+ setError(result.error ?? 'Invalid email or password');
189
+ }
190
+ } catch (err) {
191
+ setError(err instanceof Error ? err.message : 'An unexpected error occurred');
192
+ } finally {
193
+ setSubmitting(false);
194
+ }
195
+ };
196
+
197
+ const handleOAuthClick = (provider: string) => {
198
+ window.location.href = `/api/cms/auth/oauth/${provider}`;
199
+ };
200
+
201
+ const enabledProviders = oauthProviders?.filter(
202
+ (p) => ['google', 'github', 'microsoft'].includes(p),
203
+ ) ?? [];
204
+
205
+ return (
206
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
207
+ <div className="w-full max-w-md">
208
+ <div className="text-center mb-8">
209
+ <div className="mx-auto mb-4 w-14 h-14 bg-blue-600 rounded-xl flex items-center justify-center">
210
+ <Shield className="w-7 h-7 text-white" />
211
+ </div>
212
+ <h1 className="text-2xl font-bold text-gray-900">Actuate CMS</h1>
213
+ <p className="text-gray-600 mt-2">Sign in to your account</p>
214
+ </div>
215
+
216
+ <form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm space-y-5">
217
+ {error && (
218
+ <div className="flex items-start gap-3 p-3 bg-red-50 border border-red-200 rounded-lg">
219
+ <AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 shrink-0" />
220
+ <p className="text-sm text-red-800">{error}</p>
221
+ </div>
222
+ )}
223
+
224
+ <div>
225
+ <label htmlFor="login-email" className="block text-sm font-medium text-gray-700 mb-1.5">Email Address</label>
226
+ <input
227
+ id="login-email"
228
+ type="email"
229
+ value={email}
230
+ onChange={(e) => setEmail(e.target.value)}
231
+ placeholder="admin@example.com"
232
+ className="w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
233
+ required
234
+ autoFocus
235
+ autoComplete="email"
236
+ />
237
+ </div>
238
+
239
+ <div>
240
+ <div className="flex items-center justify-between mb-1.5">
241
+ <label htmlFor="login-password" className="block text-sm font-medium text-gray-700">Password</label>
242
+ {onNavigate && (
243
+ <button
244
+ type="button"
245
+ onClick={() => onNavigate('/forgot-password')}
246
+ className="text-xs text-blue-600 hover:text-blue-700 font-medium"
247
+ >
248
+ Forgot Password?
249
+ </button>
250
+ )}
251
+ </div>
252
+ <div className="relative">
253
+ <input
254
+ id="login-password"
255
+ type={showPassword ? 'text' : 'password'}
256
+ value={password}
257
+ onChange={(e) => setPassword(e.target.value)}
258
+ placeholder="Enter your password"
259
+ className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
260
+ required
261
+ autoComplete="current-password"
262
+ />
263
+ <button
264
+ type="button"
265
+ onClick={() => setShowPassword(!showPassword)}
266
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
267
+ tabIndex={-1}
268
+ >
269
+ {showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
270
+ </button>
271
+ </div>
272
+ </div>
273
+
274
+ <button
275
+ type="submit"
276
+ disabled={!canSubmit}
277
+ className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
278
+ >
279
+ {submitting ? (
280
+ <>
281
+ <Loader2 className="w-4 h-4 animate-spin" />
282
+ Signing in...
283
+ </>
284
+ ) : (
285
+ 'Sign In'
286
+ )}
287
+ </button>
288
+
289
+ {enabledProviders.length > 0 && (
290
+ <>
291
+ <div className="relative">
292
+ <div className="absolute inset-0 flex items-center">
293
+ <div className="w-full border-t border-gray-200" />
294
+ </div>
295
+ <div className="relative flex justify-center text-sm">
296
+ <span className="bg-white px-3 text-gray-500">Or continue with</span>
297
+ </div>
298
+ </div>
299
+
300
+ <div className="grid gap-3">
301
+ {enabledProviders.map((provider) => {
302
+ const colors = OAUTH_COLORS[provider] ?? OAUTH_COLORS.google;
303
+ return (
304
+ <button
305
+ key={provider}
306
+ type="button"
307
+ onClick={() => handleOAuthClick(provider)}
308
+ className={`w-full flex items-center justify-center gap-3 py-2.5 px-4 border ${colors?.border ?? ''} ${colors?.bg ?? ''} ${colors?.text ?? ''} rounded-lg font-medium text-sm ${colors?.hover ?? ''} transition-colors`}
309
+ >
310
+ <OAuthIcon provider={provider} />
311
+ {OAUTH_LABELS[provider] ?? provider}
312
+ </button>
313
+ );
314
+ })}
315
+ </div>
316
+ </>
317
+ )}
318
+ </form>
319
+ </div>
320
+ </div>
321
+ );
322
+ }