@actuate-media/cms-admin 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +16 -10
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +2 -0
- package/dist/components/TipTapEditor.js +78 -78
- package/dist/lib/useApiData.d.ts +8 -1
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +39 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +8 -3
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +10 -5
- package/src/AdminRoot.tsx +312 -0
- package/src/__tests__/lib/search.test.ts +138 -0
- package/src/__tests__/lib/utils.test.ts +19 -0
- package/src/__tests__/router/match-route.test.ts +47 -0
- package/src/__tests__/router/strip-base.test.ts +30 -0
- package/src/components/Breadcrumbs.tsx +92 -0
- package/src/components/CommandPalette.tsx +384 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/FocalPointPicker.tsx +54 -0
- package/src/components/FolderTree.tsx +427 -0
- package/src/components/LivePreview.tsx +136 -0
- package/src/components/LocaleProvider.tsx +51 -0
- package/src/components/LocaleSwitcher.tsx +51 -0
- package/src/components/MediaPickerModal.tsx +183 -0
- package/src/components/PresenceIndicator.tsx +71 -0
- package/src/components/SEOPanel.tsx +767 -0
- package/src/components/ThemeProvider.tsx +98 -0
- package/src/components/TipTapEditor.tsx +469 -0
- package/src/components/VersionHistory.tsx +167 -0
- package/src/components/ui/Avatar.tsx +42 -0
- package/src/components/ui/Badge.tsx +25 -0
- package/src/components/ui/Button.tsx +52 -0
- package/src/components/ui/CommandPalette.tsx +119 -0
- package/src/components/ui/ConfirmDialog.tsx +52 -0
- package/src/components/ui/DataTable.tsx +194 -0
- package/src/components/ui/EmptyState.tsx +29 -0
- package/src/components/ui/Modal.tsx +48 -0
- package/src/components/ui/Pagination.tsx +79 -0
- package/src/components/ui/SearchInput.tsx +44 -0
- package/src/components/ui/Skeleton.tsx +48 -0
- package/src/components/ui/Toast.tsx +66 -0
- package/src/components/ui/index.ts +24 -0
- package/src/fields/ArrayField.tsx +92 -0
- package/src/fields/BlockBuilderField.tsx +421 -0
- package/src/fields/DateField.tsx +41 -0
- package/src/fields/FieldRenderer.tsx +84 -0
- package/src/fields/GroupField.tsx +41 -0
- package/src/fields/MediaField.tsx +48 -0
- package/src/fields/NavBuilderField.tsx +78 -0
- package/src/fields/NumberField.tsx +45 -0
- package/src/fields/RelationshipField.tsx +245 -0
- package/src/fields/RichTextField.tsx +26 -0
- package/src/fields/SelectField.tsx +117 -0
- package/src/fields/SlugField.tsx +65 -0
- package/src/fields/TextField.tsx +48 -0
- package/src/fields/ToggleField.tsx +36 -0
- package/src/fields/block-types.ts +95 -0
- package/src/fields/index.ts +17 -0
- package/src/hooks/useContentLock.ts +52 -0
- package/src/hooks/useDebounce.ts +14 -0
- package/src/hooks/useKeyboardShortcuts.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout/Header.tsx +135 -0
- package/src/layout/Layout.tsx +77 -0
- package/src/layout/Sidebar.tsx +216 -0
- package/src/lib/api.ts +67 -0
- package/src/lib/search.ts +59 -0
- package/src/lib/useApiData.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/router/index.ts +81 -0
- package/src/styles/build-input.css +11 -0
- package/src/styles/tailwind.css +11 -6
- package/src/styles/theme.css +182 -181
- package/src/views/CollectionList.tsx +270 -0
- package/src/views/Dashboard.tsx +207 -0
- package/src/views/DocumentEdit.tsx +377 -0
- package/src/views/FormEditor.tsx +533 -0
- package/src/views/FormSubmissions.tsx +316 -0
- package/src/views/Forms.tsx +106 -0
- package/src/views/Login.tsx +322 -0
- package/src/views/MediaBrowser.tsx +774 -0
- package/src/views/PageEditor.tsx +192 -0
- package/src/views/Pages.tsx +354 -0
- package/src/views/PostEditor.tsx +251 -0
- package/src/views/Posts.tsx +243 -0
- package/src/views/Redirects.tsx +293 -0
- package/src/views/SEO.tsx +458 -0
- package/src/views/Settings.tsx +811 -0
- package/src/views/SetupWizard.tsx +207 -0
- package/src/views/Users.tsx +282 -0
|
@@ -0,0 +1,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'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'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
|
+
{' '}→{' '}
|
|
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 & 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 "Check for Updates" 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'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 "Create Update PR" 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
|
+
}
|