@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
@@ -0,0 +1,811 @@
1
+ 'use client';
2
+
3
+ import * as Tabs from '@radix-ui/react-tabs';
4
+ import { Bot, Eye, EyeOff, Image, FileCode2, BookOpen, Sparkles, MessageSquare, Languages, Loader2, AlertTriangle, Download, CheckCircle2, ArrowUpCircle, ExternalLink, RefreshCw, GitPullRequest } from 'lucide-react';
5
+ import { useState, useEffect } from 'react';
6
+ import { toast } from 'sonner';
7
+ import { useApiData } from '../lib/useApiData.js';
8
+ import { cmsApi } from '../lib/api.js';
9
+ import { useTheme } from '../components/ThemeProvider.js';
10
+
11
+ export interface SettingsProps {
12
+ onNavigate?: (path: string) => void;
13
+ }
14
+
15
+ export function Settings(_props: SettingsProps = {}) {
16
+ const { data, loading, error, refetch } = useApiData<any>('/globals/settings');
17
+
18
+ const [siteTitle, setSiteTitle] = useState('My CMS');
19
+ const [tagline, setTagline] = useState('A lightweight content management system');
20
+ const [siteUrl, setSiteUrl] = useState('https://example.com');
21
+ const [language, setLanguage] = useState('en');
22
+ const [timezone, setTimezone] = useState('UTC');
23
+ const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
24
+ const [sessionTimeout, setSessionTimeout] = useState(false);
25
+ const [ipWhitelist, setIpWhitelist] = useState(false);
26
+ const [activeTab, setActiveTab] = useState('general');
27
+ const [saving, setSaving] = useState(false);
28
+
29
+ // AI settings
30
+ const [aiProvider, setAiProvider] = useState('anthropic');
31
+ const [aiApiKey, setAiApiKey] = useState('');
32
+ const [showApiKey, setShowApiKey] = useState(false);
33
+ const [aiAltTags, setAiAltTags] = useState(true);
34
+ const [aiMediaCategorize, setAiMediaCategorize] = useState(false);
35
+ const [aiMetaDescriptions, setAiMetaDescriptions] = useState(true);
36
+ const [aiReadability, setAiReadability] = useState(true);
37
+ const [aiSchema, setAiSchema] = useState(true);
38
+ const [aiBrandVoice, setAiBrandVoice] = useState(false);
39
+ const [aiWritingAssistant, setAiWritingAssistant] = useState(true);
40
+ const [aiContentScoring, setAiContentScoring] = useState(true);
41
+ const [aiTranslation, setAiTranslation] = useState(false);
42
+
43
+ useEffect(() => {
44
+ if (data) {
45
+ setSiteTitle(data.siteTitle ?? 'My CMS');
46
+ setTagline(data.tagline ?? '');
47
+ setSiteUrl(data.siteUrl ?? '');
48
+ setLanguage(data.language ?? 'en');
49
+ setTimezone(data.timezone ?? 'UTC');
50
+ setTwoFactorEnabled(data.twoFactorEnabled ?? false);
51
+ setSessionTimeout(data.sessionTimeout ?? false);
52
+ setIpWhitelist(data.ipWhitelist ?? false);
53
+ setAiProvider(data.aiProvider ?? 'anthropic');
54
+ setAiAltTags(data.aiAltTags ?? true);
55
+ setAiMediaCategorize(data.aiMediaCategorize ?? false);
56
+ setAiMetaDescriptions(data.aiMetaDescriptions ?? true);
57
+ setAiReadability(data.aiReadability ?? true);
58
+ setAiSchema(data.aiSchema ?? true);
59
+ setAiBrandVoice(data.aiBrandVoice ?? false);
60
+ setAiWritingAssistant(data.aiWritingAssistant ?? true);
61
+ setAiContentScoring(data.aiContentScoring ?? true);
62
+ setAiTranslation(data.aiTranslation ?? false);
63
+ }
64
+ }, [data]);
65
+
66
+ const handleSave = async () => {
67
+ setSaving(true);
68
+ const res = await cmsApi('/globals/settings', {
69
+ method: 'PUT',
70
+ body: JSON.stringify({
71
+ siteTitle, tagline, siteUrl, language, timezone,
72
+ twoFactorEnabled, sessionTimeout, ipWhitelist,
73
+ aiProvider, aiAltTags, aiMediaCategorize, aiMetaDescriptions,
74
+ aiReadability, aiSchema, aiBrandVoice, aiWritingAssistant,
75
+ aiContentScoring, aiTranslation,
76
+ }),
77
+ });
78
+ setSaving(false);
79
+ if (res.error) {
80
+ toast.error(res.error);
81
+ } else {
82
+ toast.success('Settings saved successfully!');
83
+ }
84
+ };
85
+
86
+ const tabTriggerClass = "px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 data-[state=active]:border-b-2 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600";
87
+
88
+ if (loading) {
89
+ return (
90
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
91
+ <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
92
+ </div>
93
+ );
94
+ }
95
+
96
+ return (
97
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8">
98
+ {error && (
99
+ <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
100
+ <AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
101
+ <span className="text-sm text-red-800 flex-1">{error}</span>
102
+ <button onClick={refetch} className="px-3 py-1 text-sm text-red-700 border border-red-300 rounded-lg hover:bg-red-100 transition-colors">Retry</button>
103
+ </div>
104
+ )}
105
+
106
+ <div className="mb-4">
107
+ <h1 className="mb-1 text-2xl font-semibold text-gray-900">Settings</h1>
108
+ <p className="text-sm text-gray-600">Manage your CMS configuration</p>
109
+ </div>
110
+
111
+ <Tabs.Root value={activeTab} onValueChange={setActiveTab}>
112
+ <Tabs.List className="mb-4 flex gap-1 border-b border-gray-200 overflow-x-auto">
113
+ <Tabs.Trigger value="general" className={tabTriggerClass}>General</Tabs.Trigger>
114
+ <Tabs.Trigger value="appearance" className={tabTriggerClass}>Appearance</Tabs.Trigger>
115
+ <Tabs.Trigger value="security" className={tabTriggerClass}>Security</Tabs.Trigger>
116
+ <Tabs.Trigger value="ai" className={tabTriggerClass}>
117
+ <span className="flex items-center gap-1.5">
118
+ <Bot className="w-4 h-4" />
119
+ AI
120
+ </span>
121
+ </Tabs.Trigger>
122
+ <Tabs.Trigger value="integrations" className={tabTriggerClass}>Integrations</Tabs.Trigger>
123
+ <Tabs.Trigger value="updates" className={tabTriggerClass}>
124
+ <span className="flex items-center gap-1.5">
125
+ <Download className="w-4 h-4" />
126
+ Updates
127
+ </span>
128
+ </Tabs.Trigger>
129
+ </Tabs.List>
130
+
131
+ <Tabs.Content value="general" className="space-y-4">
132
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
133
+ <h3 className="mb-4 text-sm font-semibold text-gray-900">Site Information</h3>
134
+ <div className="space-y-4">
135
+ <div>
136
+ <label className="mb-1 block text-sm font-medium text-gray-700">Site Title</label>
137
+ <input type="text" value={siteTitle} onChange={(e) => setSiteTitle(e.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
138
+ </div>
139
+ <div>
140
+ <label className="mb-1 block text-sm font-medium text-gray-700">Tagline</label>
141
+ <input type="text" value={tagline} onChange={(e) => setTagline(e.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
142
+ </div>
143
+ <div>
144
+ <label className="mb-1 block text-sm font-medium text-gray-700">Site URL</label>
145
+ <input type="url" value={siteUrl} onChange={(e) => setSiteUrl(e.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
146
+ </div>
147
+ </div>
148
+ </div>
149
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
150
+ <h3 className="mb-4 text-sm font-semibold text-gray-900">Language & Region</h3>
151
+ <div className="space-y-4">
152
+ <div>
153
+ <label className="mb-1 block text-sm font-medium text-gray-700">Language</label>
154
+ <select value={language} onChange={(e) => setLanguage(e.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
155
+ <option value="en">English</option>
156
+ <option value="es">Spanish</option>
157
+ <option value="fr">French</option>
158
+ <option value="de">German</option>
159
+ </select>
160
+ </div>
161
+ <div>
162
+ <label className="mb-1 block text-sm font-medium text-gray-700">Timezone</label>
163
+ <select value={timezone} onChange={(e) => setTimezone(e.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
164
+ <option value="UTC">UTC</option>
165
+ <option value="America/New_York">Eastern Time</option>
166
+ <option value="America/Chicago">Central Time</option>
167
+ <option value="America/Denver">Mountain Time</option>
168
+ <option value="America/Los_Angeles">Pacific Time</option>
169
+ </select>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </Tabs.Content>
174
+
175
+ <Tabs.Content value="appearance" className="space-y-4">
176
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
177
+ <h3 className="mb-4 text-sm font-semibold text-gray-900">Theme</h3>
178
+ <div className="space-y-4">
179
+ <div>
180
+ <label className="mb-1 block text-sm font-medium text-gray-700">Color Scheme</label>
181
+ <ThemeSelect />
182
+ </div>
183
+ <div>
184
+ <label className="mb-1 block text-sm font-medium text-gray-700">Primary Color</label>
185
+ <input type="color" defaultValue="#3b82f6" className="h-10 w-full rounded-lg border border-gray-300 px-3 py-2" />
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </Tabs.Content>
190
+
191
+ <Tabs.Content value="security" className="space-y-4">
192
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
193
+ <h3 className="mb-4 text-sm font-semibold text-gray-900">Security Settings</h3>
194
+ <div className="space-y-4">
195
+ <ToggleSetting label="Two-Factor Authentication" description="Require 2FA for all admin users" checked={twoFactorEnabled} onChange={setTwoFactorEnabled} />
196
+ <ToggleSetting label="Session Timeout" description="Automatically log out inactive users after 30 minutes" checked={sessionTimeout} onChange={setSessionTimeout} />
197
+ <ToggleSetting label="IP Whitelist" description="Only allow access from approved IP addresses" checked={ipWhitelist} onChange={setIpWhitelist} />
198
+ </div>
199
+ </div>
200
+ </Tabs.Content>
201
+
202
+ <Tabs.Content value="ai" className="space-y-4">
203
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
204
+ <h3 className="mb-1 text-sm font-semibold text-gray-900">AI Provider & API Key</h3>
205
+ <p className="mb-4 text-xs text-gray-500">Connect an AI provider to enable intelligent content features</p>
206
+ <div className="space-y-4">
207
+ <div>
208
+ <label className="mb-1 block text-sm font-medium text-gray-700">Provider</label>
209
+ <select
210
+ value={aiProvider}
211
+ onChange={(e) => setAiProvider(e.target.value)}
212
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
213
+ >
214
+ <option value="anthropic">Anthropic (Claude)</option>
215
+ <option value="openai">OpenAI (GPT)</option>
216
+ <option value="google">Google (Gemini)</option>
217
+ </select>
218
+ <p className="mt-1 text-xs text-gray-500">
219
+ {aiProvider === 'anthropic' && 'Recommended. Best for content analysis, brand voice learning, and nuanced writing.'}
220
+ {aiProvider === 'openai' && 'Strong alternative. Good for general content generation and image understanding.'}
221
+ {aiProvider === 'google' && 'Multimodal. Excellent for image analysis and multilingual content.'}
222
+ </p>
223
+ </div>
224
+ <div>
225
+ <label className="mb-1 block text-sm font-medium text-gray-700">API Key</label>
226
+ <div className="flex gap-2">
227
+ <div className="relative flex-1">
228
+ <input
229
+ type={showApiKey ? 'text' : 'password'}
230
+ value={aiApiKey}
231
+ onChange={(e) => setAiApiKey(e.target.value)}
232
+ placeholder={aiProvider === 'anthropic' ? 'sk-ant-...' : aiProvider === 'openai' ? 'sk-...' : 'AIza...'}
233
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
234
+ />
235
+ <button
236
+ type="button"
237
+ onClick={() => setShowApiKey(!showApiKey)}
238
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
239
+ >
240
+ {showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
241
+ </button>
242
+ </div>
243
+ <button
244
+ type="button"
245
+ onClick={() => {
246
+ if (aiApiKey) toast.success('API key verified successfully');
247
+ else toast.error('Please enter an API key first');
248
+ }}
249
+ className="rounded-lg border border-gray-300 px-4 py-2 text-sm transition-colors hover:bg-gray-50"
250
+ >
251
+ Verify
252
+ </button>
253
+ </div>
254
+ <p className="mt-1 text-xs text-gray-500">Your key is encrypted at rest (AES-256-GCM) and never exposed to the client</p>
255
+ </div>
256
+ </div>
257
+ </div>
258
+
259
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
260
+ <div className="flex items-center gap-2 mb-1">
261
+ <Image className="w-4 h-4 text-blue-600" />
262
+ <h3 className="text-sm font-semibold text-gray-900">Media Intelligence</h3>
263
+ </div>
264
+ <p className="mb-4 text-xs text-gray-500">AI-powered image and media analysis</p>
265
+ <div className="space-y-4">
266
+ <ToggleSetting
267
+ label="Auto Alt-Tag Generation"
268
+ description="Scan uploaded images and automatically generate descriptive alt text for accessibility and SEO"
269
+ checked={aiAltTags}
270
+ onChange={setAiAltTags}
271
+ />
272
+ <ToggleSetting
273
+ label="Media Auto-Categorization"
274
+ description="Automatically tag and categorize uploaded media based on visual content analysis"
275
+ checked={aiMediaCategorize}
276
+ onChange={setAiMediaCategorize}
277
+ />
278
+ </div>
279
+ </div>
280
+
281
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
282
+ <div className="flex items-center gap-2 mb-1">
283
+ <FileCode2 className="w-4 h-4 text-purple-600" />
284
+ <h3 className="text-sm font-semibold text-gray-900">Content SEO</h3>
285
+ </div>
286
+ <p className="mb-4 text-xs text-gray-500">AI-driven SEO optimization for your content</p>
287
+ <div className="space-y-4">
288
+ <ToggleSetting
289
+ label="Meta Description Generation"
290
+ description="Auto-generate optimized meta descriptions by digesting page content"
291
+ checked={aiMetaDescriptions}
292
+ onChange={setAiMetaDescriptions}
293
+ />
294
+ <ToggleSetting
295
+ label="Readability Analysis"
296
+ description="Score content readability (Flesch-Kincaid, Gunning Fog) with improvement suggestions"
297
+ checked={aiReadability}
298
+ onChange={setAiReadability}
299
+ />
300
+ <ToggleSetting
301
+ label="Schema.org Enrichment"
302
+ description="Automatically detect content types and generate appropriate Schema.org JSON-LD markup"
303
+ checked={aiSchema}
304
+ onChange={setAiSchema}
305
+ />
306
+ </div>
307
+ </div>
308
+
309
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
310
+ <div className="flex items-center gap-2 mb-1">
311
+ <MessageSquare className="w-4 h-4 text-green-600" />
312
+ <h3 className="text-sm font-semibold text-gray-900">Brand Voice & Writing</h3>
313
+ </div>
314
+ <p className="mb-4 text-xs text-gray-500">AI that understands and writes in your brand&apos;s voice</p>
315
+ <div className="space-y-4">
316
+ <ToggleSetting
317
+ label="Brand Voice Training"
318
+ description="Analyze existing content to learn your brand's tone, style, and vocabulary"
319
+ checked={aiBrandVoice}
320
+ onChange={setAiBrandVoice}
321
+ />
322
+ <ToggleSetting
323
+ label="AI Writing Assistant"
324
+ description="In-editor AI helper for drafting, rewriting, and expanding content in your brand voice"
325
+ checked={aiWritingAssistant}
326
+ onChange={setAiWritingAssistant}
327
+ />
328
+ </div>
329
+ </div>
330
+
331
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
332
+ <div className="flex items-center gap-2 mb-1">
333
+ <Sparkles className="w-4 h-4 text-yellow-600" />
334
+ <h3 className="text-sm font-semibold text-gray-900">Content Quality</h3>
335
+ </div>
336
+ <p className="mb-4 text-xs text-gray-500">Automated quality scoring and content intelligence</p>
337
+ <div className="space-y-4">
338
+ <ToggleSetting
339
+ label="Content Scoring"
340
+ description="Automatically score content quality based on structure, readability, SEO, and completeness"
341
+ checked={aiContentScoring}
342
+ onChange={setAiContentScoring}
343
+ />
344
+ <ToggleSetting
345
+ label="AI-Powered Translation"
346
+ description="Translate content into target languages while preserving tone and meaning"
347
+ checked={aiTranslation}
348
+ onChange={setAiTranslation}
349
+ />
350
+ </div>
351
+ </div>
352
+
353
+ <div className="rounded-lg border border-indigo-200 bg-indigo-50 p-4">
354
+ <div className="flex items-center gap-2 mb-1">
355
+ <BookOpen className="w-4 h-4 text-indigo-600" />
356
+ <h3 className="text-sm font-semibold text-indigo-900">AI Usage This Month</h3>
357
+ </div>
358
+ <div className="grid grid-cols-3 gap-4 mt-3">
359
+ <div>
360
+ <div className="text-lg font-semibold text-indigo-900">0</div>
361
+ <div className="text-xs text-indigo-700">API Calls</div>
362
+ </div>
363
+ <div>
364
+ <div className="text-lg font-semibold text-indigo-900">0</div>
365
+ <div className="text-xs text-indigo-700">Tokens Used</div>
366
+ </div>
367
+ <div>
368
+ <div className="text-lg font-semibold text-indigo-900">$0.00</div>
369
+ <div className="text-xs text-indigo-700">Estimated Cost</div>
370
+ </div>
371
+ </div>
372
+ <p className="mt-2 text-xs text-indigo-600">Add an API key above to start using AI features</p>
373
+ </div>
374
+ </Tabs.Content>
375
+
376
+ <Tabs.Content value="integrations" className="space-y-4">
377
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
378
+ <h3 className="mb-4 text-sm font-semibold text-gray-900">API Keys</h3>
379
+ <div className="space-y-4">
380
+ <div>
381
+ <label className="mb-1 block text-sm font-medium text-gray-700">API Key</label>
382
+ <div className="flex gap-2">
383
+ <input type="text" value="•••••••••••••••••••••••••" readOnly className="flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-sm" />
384
+ <button type="button" className="rounded-lg border border-gray-300 px-4 py-2 text-sm transition-colors hover:bg-gray-50">Regenerate</button>
385
+ </div>
386
+ </div>
387
+ </div>
388
+ </div>
389
+ </Tabs.Content>
390
+
391
+ <Tabs.Content value="updates" className="space-y-4">
392
+ <UpdatesPanel />
393
+ </Tabs.Content>
394
+ </Tabs.Root>
395
+
396
+ <div className="mt-6 flex justify-end">
397
+ <button
398
+ type="button"
399
+ onClick={handleSave}
400
+ disabled={saving}
401
+ className="rounded-lg bg-blue-600 px-6 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
402
+ >
403
+ {saving ? 'Saving...' : 'Save Changes'}
404
+ </button>
405
+ </div>
406
+ </div>
407
+ );
408
+ }
409
+
410
+ function ThemeSelect() {
411
+ const { theme, setTheme } = useTheme();
412
+ return (
413
+ <select
414
+ value={theme}
415
+ onChange={(e) => setTheme(e.target.value as 'light' | 'dark' | 'system')}
416
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
417
+ >
418
+ <option value="light">Light</option>
419
+ <option value="dark">Dark</option>
420
+ <option value="system">Auto (System)</option>
421
+ </select>
422
+ );
423
+ }
424
+
425
+ function ToggleSetting({
426
+ label,
427
+ description,
428
+ checked,
429
+ onChange,
430
+ }: {
431
+ label: string;
432
+ description: string;
433
+ checked: boolean;
434
+ onChange: (v: boolean) => void;
435
+ }) {
436
+ return (
437
+ <div className="flex items-center justify-between gap-4">
438
+ <div className="flex-1">
439
+ <label className="text-sm font-medium text-gray-700">{label}</label>
440
+ <p className="mt-0.5 text-xs text-gray-500">{description}</p>
441
+ </div>
442
+ <button
443
+ type="button"
444
+ onClick={() => onChange(!checked)}
445
+ className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-blue-600' : 'bg-gray-300'}`}
446
+ aria-pressed={checked}
447
+ >
448
+ <span
449
+ className={`absolute top-0.5 block h-5 w-5 rounded-full bg-white transition-transform ${
450
+ checked ? 'translate-x-[22px]' : 'translate-x-0.5'
451
+ }`}
452
+ />
453
+ </button>
454
+ </div>
455
+ );
456
+ }
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // Updates Panel
460
+ // ---------------------------------------------------------------------------
461
+
462
+ interface UpdateInfo {
463
+ current: string;
464
+ latest: string;
465
+ updateAvailable: boolean;
466
+ severity?: 'patch' | 'minor' | 'major';
467
+ releaseDate?: string;
468
+ changelog?: Array<{ version: string; date: string; summary: string }>;
469
+ updateCommand?: string;
470
+ hasGithubToken?: boolean;
471
+ githubRepo?: string;
472
+ }
473
+
474
+ function UpdatesPanel() {
475
+ const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
476
+ const [checking, setChecking] = useState(false);
477
+ const [checkError, setCheckError] = useState('');
478
+ const [applying, setApplying] = useState(false);
479
+ const [prResult, setPrResult] = useState<{ prUrl: string; prNumber: number } | null>(null);
480
+ const [hasChecked, setHasChecked] = useState(false);
481
+
482
+ // GitHub config
483
+ const [ghToken, setGhToken] = useState('');
484
+ const [ghRepo, setGhRepo] = useState('');
485
+ const [showGhToken, setShowGhToken] = useState(false);
486
+ const [savingConfig, setSavingConfig] = useState(false);
487
+ const [configSaved, setConfigSaved] = useState(false);
488
+
489
+ const checkForUpdates = async () => {
490
+ setChecking(true);
491
+ setCheckError('');
492
+ setPrResult(null);
493
+
494
+ try {
495
+ const res = await cmsApi<UpdateInfo>('/updates/check');
496
+ if (res.error) {
497
+ setCheckError(res.error);
498
+ } else if (res.data) {
499
+ setUpdateInfo(res.data);
500
+ if (res.data.githubRepo) setGhRepo(res.data.githubRepo);
501
+ }
502
+ } catch {
503
+ setCheckError('Unable to check for updates. Please try again.');
504
+ } finally {
505
+ setChecking(false);
506
+ setHasChecked(true);
507
+ }
508
+ };
509
+
510
+ const saveGitHubConfig = async () => {
511
+ setSavingConfig(true);
512
+ setConfigSaved(false);
513
+ try {
514
+ const res = await cmsApi('/updates/config', {
515
+ method: 'PUT',
516
+ body: JSON.stringify({
517
+ ...(ghToken ? { githubToken: ghToken } : {}),
518
+ ...(ghRepo ? { githubRepo: ghRepo } : {}),
519
+ }),
520
+ });
521
+ if (res.error) {
522
+ toast.error(res.error);
523
+ } else {
524
+ toast.success('GitHub configuration saved and encrypted.');
525
+ setConfigSaved(true);
526
+ setGhToken('');
527
+ if (updateInfo) {
528
+ setUpdateInfo({ ...updateInfo, hasGithubToken: true, githubRepo: ghRepo });
529
+ }
530
+ }
531
+ } catch {
532
+ toast.error('Failed to save configuration.');
533
+ } finally {
534
+ setSavingConfig(false);
535
+ }
536
+ };
537
+
538
+ const applyUpdate = async () => {
539
+ if (!updateInfo?.latest) return;
540
+ setApplying(true);
541
+
542
+ try {
543
+ const res = await cmsApi<{ prUrl: string; prNumber: number }>('/updates/apply', {
544
+ method: 'POST',
545
+ body: JSON.stringify({ targetVersion: updateInfo.latest }),
546
+ });
547
+
548
+ if (res.error) {
549
+ toast.error(res.error);
550
+ } else if (res.data) {
551
+ setPrResult(res.data);
552
+ toast.success('Update PR created successfully!');
553
+ }
554
+ } catch {
555
+ toast.error('Failed to create update PR. Check your GitHub configuration.');
556
+ } finally {
557
+ setApplying(false);
558
+ }
559
+ };
560
+
561
+ useEffect(() => {
562
+ checkForUpdates();
563
+ }, []);
564
+
565
+ const severityColors: Record<string, { bg: string; text: string; border: string; label: string }> = {
566
+ patch: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', label: 'Patch' },
567
+ minor: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200', label: 'Minor' },
568
+ major: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', label: 'Major' },
569
+ };
570
+
571
+ return (
572
+ <>
573
+ {/* Current Version Card */}
574
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
575
+ <div className="flex items-center justify-between">
576
+ <div>
577
+ <h3 className="text-sm font-semibold text-gray-900">Actuate CMS</h3>
578
+ <p className="mt-1 text-sm text-gray-600">
579
+ Current version: <span className="font-mono font-medium">{updateInfo?.current ?? '...'}</span>
580
+ </p>
581
+ </div>
582
+ <button
583
+ type="button"
584
+ onClick={checkForUpdates}
585
+ disabled={checking}
586
+ className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50"
587
+ >
588
+ <RefreshCw className={`w-4 h-4 ${checking ? 'animate-spin' : ''}`} />
589
+ {checking ? 'Checking...' : 'Check for Updates'}
590
+ </button>
591
+ </div>
592
+ </div>
593
+
594
+ {/* Error State */}
595
+ {checkError && (
596
+ <div className="flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
597
+ <AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
598
+ <span className="text-sm text-red-800 flex-1">{checkError}</span>
599
+ <button onClick={checkForUpdates} className="px-3 py-1 text-sm text-red-700 border border-red-300 rounded-lg hover:bg-red-100 transition-colors">Retry</button>
600
+ </div>
601
+ )}
602
+
603
+ {/* Up to Date */}
604
+ {hasChecked && updateInfo && !updateInfo.updateAvailable && !checkError && (
605
+ <div className="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
606
+ <CheckCircle2 className="w-6 h-6 text-green-600 shrink-0" />
607
+ <div>
608
+ <h3 className="text-sm font-semibold text-green-900">You&apos;re up to date!</h3>
609
+ <p className="text-sm text-green-700 mt-0.5">
610
+ Actuate CMS <span className="font-mono">{updateInfo.current}</span> is the latest version.
611
+ </p>
612
+ </div>
613
+ </div>
614
+ )}
615
+
616
+ {/* Update Available */}
617
+ {updateInfo?.updateAvailable && (
618
+ <>
619
+ <div className={`rounded-lg border p-4 ${severityColors[updateInfo.severity ?? 'patch']?.border ?? 'border-blue-200'} ${severityColors[updateInfo.severity ?? 'patch']?.bg ?? 'bg-blue-50'}`}>
620
+ <div className="flex items-start gap-3">
621
+ <ArrowUpCircle className={`w-6 h-6 mt-0.5 shrink-0 ${severityColors[updateInfo.severity ?? 'patch']?.text ?? 'text-blue-700'}`} />
622
+ <div className="flex-1">
623
+ <div className="flex items-center gap-2">
624
+ <h3 className={`text-sm font-semibold ${severityColors[updateInfo.severity ?? 'patch']?.text ?? 'text-blue-700'}`}>
625
+ Update Available
626
+ </h3>
627
+ <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${severityColors[updateInfo.severity ?? 'patch']?.bg ?? 'bg-blue-50'} ${severityColors[updateInfo.severity ?? 'patch']?.text ?? 'text-blue-700'} border ${severityColors[updateInfo.severity ?? 'patch']?.border ?? 'border-blue-200'}`}>
628
+ {severityColors[updateInfo.severity ?? 'patch']?.label ?? 'Update'}
629
+ </span>
630
+ </div>
631
+ <p className="text-sm mt-1" style={{ color: 'inherit' }}>
632
+ <span className="font-mono">{updateInfo.current}</span>
633
+ {' '}&rarr;{' '}
634
+ <span className="font-mono font-semibold">{updateInfo.latest}</span>
635
+ {updateInfo.releaseDate && (
636
+ <span className="text-xs ml-2 opacity-70">Released {updateInfo.releaseDate}</span>
637
+ )}
638
+ </p>
639
+ </div>
640
+ </div>
641
+
642
+ <div className="mt-4 flex items-center gap-3">
643
+ <button
644
+ type="button"
645
+ onClick={applyUpdate}
646
+ disabled={applying}
647
+ className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
648
+ >
649
+ {applying ? (
650
+ <>
651
+ <Loader2 className="w-4 h-4 animate-spin" />
652
+ Creating PR...
653
+ </>
654
+ ) : (
655
+ <>
656
+ <GitPullRequest className="w-4 h-4" />
657
+ Create Update PR
658
+ </>
659
+ )}
660
+ </button>
661
+ <span className="text-xs text-gray-500">Opens a pull request on your repository</span>
662
+ </div>
663
+
664
+ {updateInfo.updateCommand && (
665
+ <div className="mt-3 rounded border border-gray-200 bg-white p-3">
666
+ <p className="text-xs text-gray-500 mb-1">Or update manually:</p>
667
+ <code className="block text-xs font-mono text-gray-800 bg-gray-50 rounded px-2 py-1.5 select-all">
668
+ {updateInfo.updateCommand}
669
+ </code>
670
+ </div>
671
+ )}
672
+ </div>
673
+
674
+ {/* Changelog */}
675
+ {updateInfo.changelog && updateInfo.changelog.length > 0 && (
676
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
677
+ <h3 className="text-sm font-semibold text-gray-900 mb-3">Changelog</h3>
678
+ <div className="space-y-2 max-h-64 overflow-y-auto">
679
+ {updateInfo.changelog.map((entry) => (
680
+ <div key={entry.version} className="flex items-baseline gap-3 text-sm">
681
+ <span className="font-mono text-xs text-gray-500 shrink-0 w-14">{entry.version}</span>
682
+ <span className="text-xs text-gray-400 shrink-0 w-20">{entry.date}</span>
683
+ <span className="text-gray-700">{entry.summary}</span>
684
+ </div>
685
+ ))}
686
+ </div>
687
+ </div>
688
+ )}
689
+ </>
690
+ )}
691
+
692
+ {/* PR Created */}
693
+ {prResult && (
694
+ <div className="rounded-lg border border-green-200 bg-green-50 p-4">
695
+ <div className="flex items-start gap-3">
696
+ <GitPullRequest className="w-5 h-5 text-green-600 mt-0.5 shrink-0" />
697
+ <div>
698
+ <h3 className="text-sm font-semibold text-green-900">Pull Request Created</h3>
699
+ <p className="text-sm text-green-700 mt-1">
700
+ PR #{prResult.prNumber} has been created on your repository.
701
+ Review and merge it to apply the update, then run <code className="text-xs font-mono bg-green-100 px-1 rounded">npx prisma migrate deploy</code>.
702
+ </p>
703
+ <a
704
+ href={prResult.prUrl}
705
+ target="_blank"
706
+ rel="noopener noreferrer"
707
+ className="inline-flex items-center gap-1.5 mt-2 text-sm font-medium text-green-700 hover:text-green-800"
708
+ >
709
+ <ExternalLink className="w-4 h-4" />
710
+ View Pull Request
711
+ </a>
712
+ </div>
713
+ </div>
714
+ </div>
715
+ )}
716
+
717
+ {/* GitHub Configuration */}
718
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
719
+ <h3 className="text-sm font-semibold text-gray-900 mb-1">GitHub Integration</h3>
720
+ <p className="text-xs text-gray-500 mb-4">
721
+ Connect your repository to enable one-click update PRs. Credentials are encrypted at rest (AES-256-GCM).
722
+ </p>
723
+
724
+ <div className="space-y-4">
725
+ <div>
726
+ <label className="mb-1 block text-sm font-medium text-gray-700">Repository</label>
727
+ <input
728
+ type="text"
729
+ value={ghRepo}
730
+ onChange={(e) => setGhRepo(e.target.value)}
731
+ placeholder="owner/repo"
732
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
733
+ />
734
+ <p className="mt-1 text-xs text-gray-500">e.g. <code className="font-mono bg-gray-100 px-1 rounded">actuate-media/my-client-site</code></p>
735
+ </div>
736
+ <div>
737
+ <label className="mb-1 block text-sm font-medium text-gray-700">
738
+ GitHub Token
739
+ {updateInfo?.hasGithubToken && !ghToken && (
740
+ <span className="ml-2 text-xs font-normal text-green-600">Saved</span>
741
+ )}
742
+ </label>
743
+ <div className="flex gap-2">
744
+ <div className="relative flex-1">
745
+ <input
746
+ type={showGhToken ? 'text' : 'password'}
747
+ value={ghToken}
748
+ onChange={(e) => setGhToken(e.target.value)}
749
+ placeholder={updateInfo?.hasGithubToken ? '••••••••••••••••' : 'ghp_... or github_pat_...'}
750
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
751
+ />
752
+ <button
753
+ type="button"
754
+ onClick={() => setShowGhToken(!showGhToken)}
755
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
756
+ >
757
+ {showGhToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
758
+ </button>
759
+ </div>
760
+ </div>
761
+ <p className="mt-1 text-xs text-gray-500">
762
+ Needs <code className="font-mono bg-gray-100 px-1 rounded">repo</code> scope. Create at{' '}
763
+ <a href="https://github.com/settings/tokens" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
764
+ github.com/settings/tokens
765
+ </a>
766
+ </p>
767
+ </div>
768
+
769
+ <button
770
+ type="button"
771
+ onClick={saveGitHubConfig}
772
+ disabled={savingConfig || (!ghToken && !ghRepo)}
773
+ className="flex items-center gap-2 rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-800 disabled:opacity-50"
774
+ >
775
+ {savingConfig ? (
776
+ <>
777
+ <Loader2 className="w-4 h-4 animate-spin" />
778
+ Encrypting &amp; Saving...
779
+ </>
780
+ ) : (
781
+ 'Save Configuration'
782
+ )}
783
+ </button>
784
+ </div>
785
+ </div>
786
+
787
+ {/* Help Text */}
788
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
789
+ <h3 className="text-sm font-semibold text-gray-700 mb-2">How Updates Work</h3>
790
+ <ul className="space-y-1.5 text-xs text-gray-600">
791
+ <li className="flex items-start gap-2">
792
+ <span className="w-4 h-4 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">1</span>
793
+ <span>Click &quot;Check for Updates&quot; to see if a new version is available on npm.</span>
794
+ </li>
795
+ <li className="flex items-start gap-2">
796
+ <span className="w-4 h-4 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">2</span>
797
+ <span>Add your GitHub token and repository above. They&apos;re encrypted at rest — never stored in plaintext.</span>
798
+ </li>
799
+ <li className="flex items-start gap-2">
800
+ <span className="w-4 h-4 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">3</span>
801
+ <span>Click &quot;Create Update PR&quot; to open a pull request that bumps your CMS packages automatically.</span>
802
+ </li>
803
+ <li className="flex items-start gap-2">
804
+ <span className="w-4 h-4 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">4</span>
805
+ <span>Review and merge the PR, then deploy. Database migrations run automatically via <code className="font-mono bg-gray-200 px-1 rounded">prisma migrate deploy</code>.</span>
806
+ </li>
807
+ </ul>
808
+ </div>
809
+ </>
810
+ );
811
+ }