@actuate-media/cms-admin 0.24.0 → 0.26.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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -3
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/views/general-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/general-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/general-settings.render.test.js +105 -0
- package/dist/__tests__/views/general-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.js +48 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.js.map +1 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.js +44 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/views/Settings.d.ts +3 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +9 -28
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/page-builder/BlockEditor.d.ts.map +1 -1
- package/dist/views/page-builder/BlockEditor.js +2 -1
- package/dist/views/page-builder/BlockEditor.js.map +1 -1
- package/dist/views/page-builder/RelationshipField.d.ts +15 -0
- package/dist/views/page-builder/RelationshipField.d.ts.map +1 -0
- package/dist/views/page-builder/RelationshipField.js +65 -0
- package/dist/views/page-builder/RelationshipField.js.map +1 -0
- package/dist/views/page-builder/block-renderers/FormPreview.d.ts +6 -0
- package/dist/views/page-builder/block-renderers/FormPreview.d.ts.map +1 -1
- package/dist/views/page-builder/block-renderers/FormPreview.js +44 -6
- package/dist/views/page-builder/block-renderers/FormPreview.js.map +1 -1
- package/dist/views/page-builder/canvas/BlockRenderer.d.ts.map +1 -1
- package/dist/views/page-builder/canvas/BlockRenderer.js +3 -9
- package/dist/views/page-builder/canvas/BlockRenderer.js.map +1 -1
- package/dist/views/settings/GeneralSettingsTab.d.ts +7 -0
- package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/GeneralSettingsTab.js +19 -0
- package/dist/views/settings/GeneralSettingsTab.js.map +1 -0
- package/dist/views/settings/LanguageRegionCard.d.ts +10 -0
- package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -0
- package/dist/views/settings/LanguageRegionCard.js +48 -0
- package/dist/views/settings/LanguageRegionCard.js.map +1 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.d.ts +12 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.js +45 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -0
- package/dist/views/settings/SiteIndexStatusCard.d.ts +12 -0
- package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -0
- package/dist/views/settings/SiteIndexStatusCard.js +51 -0
- package/dist/views/settings/SiteIndexStatusCard.js.map +1 -0
- package/dist/views/settings/SiteInformationCard.d.ts +11 -0
- package/dist/views/settings/SiteInformationCard.d.ts.map +1 -0
- package/dist/views/settings/SiteInformationCard.js +21 -0
- package/dist/views/settings/SiteInformationCard.js.map +1 -0
- package/dist/views/settings/components.d.ts +38 -0
- package/dist/views/settings/components.d.ts.map +1 -0
- package/dist/views/settings/components.js +43 -0
- package/dist/views/settings/components.js.map +1 -0
- package/dist/views/settings/useGeneralSettings.d.ts +79 -0
- package/dist/views/settings/useGeneralSettings.d.ts.map +1 -0
- package/dist/views/settings/useGeneralSettings.js +286 -0
- package/dist/views/settings/useGeneralSettings.js.map +1 -0
- package/package.json +3 -3
- package/src/AdminRoot.tsx +5 -3
- package/src/__tests__/views/general-settings.render.test.tsx +135 -0
- package/src/__tests__/views/page-builder-block-renderer.render.test.tsx +66 -0
- package/src/__tests__/views/page-builder-form-picker.render.test.tsx +54 -0
- package/src/views/Settings.tsx +17 -120
- package/src/views/page-builder/BlockEditor.tsx +7 -10
- package/src/views/page-builder/RelationshipField.tsx +113 -0
- package/src/views/page-builder/block-renderers/FormPreview.tsx +85 -33
- package/src/views/page-builder/canvas/BlockRenderer.tsx +10 -12
- package/src/views/settings/GeneralSettingsTab.tsx +126 -0
- package/src/views/settings/LanguageRegionCard.tsx +119 -0
- package/src/views/settings/SeoRobotsDefaultsCard.tsx +149 -0
- package/src/views/settings/SiteIndexStatusCard.tsx +184 -0
- package/src/views/settings/SiteInformationCard.tsx +122 -0
- package/src/views/settings/components.tsx +198 -0
- package/src/views/settings/useGeneralSettings.ts +376 -0
package/src/views/Settings.tsx
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
BookOpen,
|
|
11
11
|
Sparkles,
|
|
12
12
|
MessageSquare,
|
|
13
|
-
Languages,
|
|
14
13
|
Loader2,
|
|
15
14
|
AlertTriangle,
|
|
16
15
|
Download,
|
|
@@ -30,36 +29,36 @@ import { cmsApi } from '../lib/api.js'
|
|
|
30
29
|
import { useTheme } from '../components/ThemeProvider.js'
|
|
31
30
|
import { RelationshipField } from '../fields/RelationshipField.js'
|
|
32
31
|
import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
|
|
32
|
+
import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
|
|
33
33
|
import { Users as UsersView } from './Users.js'
|
|
34
34
|
import { ApiKeys as ApiKeysView } from './ApiKeys.js'
|
|
35
35
|
|
|
36
36
|
export interface SettingsProps {
|
|
37
37
|
onNavigate?: (path: string) => void
|
|
38
38
|
config?: any
|
|
39
|
+
/** Current admin session — used to gate editing on role. */
|
|
40
|
+
session?: any
|
|
39
41
|
/** Initial tab when arriving without a `?tab=` query param (e.g. via the
|
|
40
42
|
* legacy `/users` or `/api-keys` routes). */
|
|
41
43
|
initialTab?: string
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/** Tabs that drive their own persistence (no global "Save Changes" footer). */
|
|
45
|
-
const SELF_MANAGED_TABS = new Set(['users', 'api-keys'])
|
|
47
|
+
const SELF_MANAGED_TABS = new Set(['general', 'users', 'api-keys'])
|
|
46
48
|
|
|
47
49
|
function readSettingsTabFromUrl(): string | null {
|
|
48
50
|
if (typeof window === 'undefined') return null
|
|
49
51
|
return new URLSearchParams(window.location.search).get('tab')
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
export function Settings({
|
|
54
|
+
export function Settings({
|
|
55
|
+
config,
|
|
56
|
+
onNavigate,
|
|
57
|
+
session,
|
|
58
|
+
initialTab = 'general',
|
|
59
|
+
}: SettingsProps = {}) {
|
|
53
60
|
const { data, loading, error, refetch } = useApiData<any>('/globals/settings')
|
|
54
61
|
|
|
55
|
-
const [siteTitle, setSiteTitle] = useState('My CMS')
|
|
56
|
-
const [tagline, setTagline] = useState('A lightweight content management system')
|
|
57
|
-
const [siteUrl, setSiteUrl] = useState('https://example.com')
|
|
58
|
-
const [language, setLanguage] = useState('en')
|
|
59
|
-
const [timezone, setTimezone] = useState('UTC')
|
|
60
|
-
const [defaultNoIndex, setDefaultNoIndex] = useState(false)
|
|
61
|
-
const [defaultNoFollow, setDefaultNoFollow] = useState(false)
|
|
62
|
-
const [noIndexNonProduction, setNoIndexNonProduction] = useState(false)
|
|
63
62
|
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false)
|
|
64
63
|
const [sessionTimeout, setSessionTimeout] = useState(false)
|
|
65
64
|
const [ipWhitelist, setIpWhitelist] = useState(false)
|
|
@@ -139,14 +138,6 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
|
|
|
139
138
|
|
|
140
139
|
useEffect(() => {
|
|
141
140
|
if (data) {
|
|
142
|
-
setSiteTitle(data.siteTitle ?? 'My CMS')
|
|
143
|
-
setTagline(data.tagline ?? '')
|
|
144
|
-
setSiteUrl(data.siteUrl ?? '')
|
|
145
|
-
setLanguage(data.language ?? 'en')
|
|
146
|
-
setTimezone(data.timezone ?? 'UTC')
|
|
147
|
-
setDefaultNoIndex(data.defaultNoIndex ?? false)
|
|
148
|
-
setDefaultNoFollow(data.defaultNoFollow ?? false)
|
|
149
|
-
setNoIndexNonProduction(data.noIndexNonProduction ?? false)
|
|
150
141
|
setTwoFactorEnabled(data.twoFactorEnabled ?? false)
|
|
151
142
|
setSessionTimeout(data.sessionTimeout ?? false)
|
|
152
143
|
setIpWhitelist(data.ipWhitelist ?? false)
|
|
@@ -169,17 +160,13 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
|
|
|
169
160
|
const handleSave = async () => {
|
|
170
161
|
setSaving(true)
|
|
171
162
|
const layoutPayload = Object.keys(defaultLayout).length > 0 ? { defaultLayout } : {}
|
|
163
|
+
// Read-modify-write: the General tab owns site info / SEO defaults in the
|
|
164
|
+
// same global blob, so merge against a fresh fetch instead of overwriting.
|
|
165
|
+
const current = await cmsApi<Record<string, unknown>>('/globals/settings')
|
|
172
166
|
const res = await cmsApi('/globals/settings', {
|
|
173
167
|
method: 'PUT',
|
|
174
168
|
body: JSON.stringify({
|
|
175
|
-
|
|
176
|
-
tagline,
|
|
177
|
-
siteUrl,
|
|
178
|
-
language,
|
|
179
|
-
timezone,
|
|
180
|
-
defaultNoIndex,
|
|
181
|
-
defaultNoFollow,
|
|
182
|
-
noIndexNonProduction,
|
|
169
|
+
...(current.data ?? {}),
|
|
183
170
|
twoFactorEnabled,
|
|
184
171
|
sessionTimeout,
|
|
185
172
|
ipWhitelist,
|
|
@@ -205,7 +192,7 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
|
|
|
205
192
|
}
|
|
206
193
|
|
|
207
194
|
const tabTriggerClass =
|
|
208
|
-
'
|
|
195
|
+
'text-muted-foreground hover:text-foreground data-[state=active]:border-brand data-[state=active]:text-brand px-4 py-2 text-base font-medium transition-colors data-[state=active]:border-b-2'
|
|
209
196
|
|
|
210
197
|
if (loading) {
|
|
211
198
|
return (
|
|
@@ -283,98 +270,8 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
|
|
|
283
270
|
</Tabs.Trigger>
|
|
284
271
|
</Tabs.List>
|
|
285
272
|
|
|
286
|
-
<Tabs.Content value="general"
|
|
287
|
-
<
|
|
288
|
-
<h3 className="mb-4 text-sm font-semibold text-gray-900">Site Information</h3>
|
|
289
|
-
<div className="space-y-4">
|
|
290
|
-
<div>
|
|
291
|
-
<label className="mb-1 block text-sm font-medium text-gray-700">Site Title</label>
|
|
292
|
-
<input
|
|
293
|
-
type="text"
|
|
294
|
-
value={siteTitle}
|
|
295
|
-
onChange={(e) => setSiteTitle(e.target.value)}
|
|
296
|
-
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
297
|
-
/>
|
|
298
|
-
</div>
|
|
299
|
-
<div>
|
|
300
|
-
<label className="mb-1 block text-sm font-medium text-gray-700">Tagline</label>
|
|
301
|
-
<input
|
|
302
|
-
type="text"
|
|
303
|
-
value={tagline}
|
|
304
|
-
onChange={(e) => setTagline(e.target.value)}
|
|
305
|
-
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
306
|
-
/>
|
|
307
|
-
</div>
|
|
308
|
-
<div>
|
|
309
|
-
<label className="mb-1 block text-sm font-medium text-gray-700">Site URL</label>
|
|
310
|
-
<input
|
|
311
|
-
type="url"
|
|
312
|
-
value={siteUrl}
|
|
313
|
-
onChange={(e) => setSiteUrl(e.target.value)}
|
|
314
|
-
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
315
|
-
/>
|
|
316
|
-
</div>
|
|
317
|
-
</div>
|
|
318
|
-
</div>
|
|
319
|
-
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|
320
|
-
<h3 className="mb-4 text-sm font-semibold text-gray-900">Language & Region</h3>
|
|
321
|
-
<div className="space-y-4">
|
|
322
|
-
<div>
|
|
323
|
-
<label className="mb-1 block text-sm font-medium text-gray-700">Language</label>
|
|
324
|
-
<select
|
|
325
|
-
value={language}
|
|
326
|
-
onChange={(e) => setLanguage(e.target.value)}
|
|
327
|
-
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
328
|
-
>
|
|
329
|
-
<option value="en">English</option>
|
|
330
|
-
<option value="es">Spanish</option>
|
|
331
|
-
<option value="fr">French</option>
|
|
332
|
-
<option value="de">German</option>
|
|
333
|
-
</select>
|
|
334
|
-
</div>
|
|
335
|
-
<div>
|
|
336
|
-
<label className="mb-1 block text-sm font-medium text-gray-700">Timezone</label>
|
|
337
|
-
<select
|
|
338
|
-
value={timezone}
|
|
339
|
-
onChange={(e) => setTimezone(e.target.value)}
|
|
340
|
-
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
341
|
-
>
|
|
342
|
-
<option value="UTC">UTC</option>
|
|
343
|
-
<option value="America/New_York">Eastern Time</option>
|
|
344
|
-
<option value="America/Chicago">Central Time</option>
|
|
345
|
-
<option value="America/Denver">Mountain Time</option>
|
|
346
|
-
<option value="America/Los_Angeles">Pacific Time</option>
|
|
347
|
-
</select>
|
|
348
|
-
</div>
|
|
349
|
-
</div>
|
|
350
|
-
</div>
|
|
351
|
-
<div className="border-border bg-card rounded-lg border p-4">
|
|
352
|
-
<h3 className="text-foreground mb-1 text-sm font-medium">SEO & Robots Defaults</h3>
|
|
353
|
-
<p className="text-muted-foreground mb-4 text-xs">
|
|
354
|
-
Set the site-wide default for search engine indexing. Individual pages can inherit or
|
|
355
|
-
override these rules in their SEO panel.
|
|
356
|
-
</p>
|
|
357
|
-
<div className="space-y-4">
|
|
358
|
-
<ToggleSetting
|
|
359
|
-
label="Default No Index"
|
|
360
|
-
description="Ask search engines not to index pages unless a page explicitly allows indexing"
|
|
361
|
-
checked={defaultNoIndex}
|
|
362
|
-
onChange={setDefaultNoIndex}
|
|
363
|
-
/>
|
|
364
|
-
<ToggleSetting
|
|
365
|
-
label="Default No Follow"
|
|
366
|
-
description="Ask search engines not to follow page links unless a page explicitly allows following"
|
|
367
|
-
checked={defaultNoFollow}
|
|
368
|
-
onChange={setDefaultNoFollow}
|
|
369
|
-
/>
|
|
370
|
-
<ToggleSetting
|
|
371
|
-
label="Noindex Non-Production Environments"
|
|
372
|
-
description="Force noindex when the deployed environment is not production"
|
|
373
|
-
checked={noIndexNonProduction}
|
|
374
|
-
onChange={setNoIndexNonProduction}
|
|
375
|
-
/>
|
|
376
|
-
</div>
|
|
377
|
-
</div>
|
|
273
|
+
<Tabs.Content value="general">
|
|
274
|
+
<GeneralSettingsTab onNavigate={onNavigate} role={session?.user?.role} />
|
|
378
275
|
</Tabs.Content>
|
|
379
276
|
|
|
380
277
|
<Tabs.Content value="appearance" className="space-y-4">
|
|
@@ -6,6 +6,7 @@ import * as SwitchPrimitive from '@radix-ui/react-switch'
|
|
|
6
6
|
import type { BlockNode, BlockTypeDefinition, FieldDefinition } from '@actuate-media/cms-core'
|
|
7
7
|
import { BlockCatalog } from '@actuate-media/cms-core'
|
|
8
8
|
import { AIBlockAssist } from './AIBlockAssist.js'
|
|
9
|
+
import { RelationshipField } from './RelationshipField.js'
|
|
9
10
|
import { TipTapEditor } from '../../components/TipTapEditor.js'
|
|
10
11
|
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
11
12
|
|
|
@@ -297,16 +298,12 @@ function FieldRenderer({ name, definition, value, onChange }: FieldRendererProps
|
|
|
297
298
|
|
|
298
299
|
case 'relationship':
|
|
299
300
|
return (
|
|
300
|
-
<
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
placeholder="Select relationship..."
|
|
307
|
-
className={INPUT_CLASS}
|
|
308
|
-
/>
|
|
309
|
-
</div>
|
|
301
|
+
<RelationshipField
|
|
302
|
+
definition={definition}
|
|
303
|
+
value={value}
|
|
304
|
+
onChange={onChange}
|
|
305
|
+
label={label}
|
|
306
|
+
/>
|
|
310
307
|
)
|
|
311
308
|
|
|
312
309
|
case 'email':
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import type { FieldDefinition } from '@actuate-media/cms-core'
|
|
5
|
+
import { cmsApi } from '../../lib/api.js'
|
|
6
|
+
|
|
7
|
+
const INPUT_CLASS =
|
|
8
|
+
'w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring'
|
|
9
|
+
const LABEL_CLASS = 'text-sm font-medium text-foreground mb-1 block'
|
|
10
|
+
|
|
11
|
+
interface RelationshipOption {
|
|
12
|
+
id: string
|
|
13
|
+
label: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RelationshipFieldProps {
|
|
17
|
+
definition: FieldDefinition
|
|
18
|
+
value: unknown
|
|
19
|
+
onChange: (value: unknown) => void
|
|
20
|
+
label: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Relationship picker for page-builder block fields (e.g. the form block's
|
|
25
|
+
* `formId`). Loads the target collection via the admin API and renders a
|
|
26
|
+
* labelled dropdown. If the collection can't be loaded it degrades to a plain
|
|
27
|
+
* id input so the field is never a dead end.
|
|
28
|
+
*/
|
|
29
|
+
export function RelationshipField({ definition, value, onChange, label }: RelationshipFieldProps) {
|
|
30
|
+
const relationTo = useMemo(() => {
|
|
31
|
+
if (definition.type !== 'relationship') return ''
|
|
32
|
+
return Array.isArray(definition.relationTo)
|
|
33
|
+
? (definition.relationTo[0] ?? '')
|
|
34
|
+
: definition.relationTo
|
|
35
|
+
}, [definition])
|
|
36
|
+
|
|
37
|
+
const [options, setOptions] = useState<RelationshipOption[]>([])
|
|
38
|
+
const [loading, setLoading] = useState(false)
|
|
39
|
+
const [failed, setFailed] = useState(false)
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!relationTo) return
|
|
43
|
+
let cancelled = false
|
|
44
|
+
setLoading(true)
|
|
45
|
+
setFailed(false)
|
|
46
|
+
cmsApi<Array<Record<string, unknown>>>(`/${relationTo}`)
|
|
47
|
+
.then((res) => {
|
|
48
|
+
if (cancelled) return
|
|
49
|
+
if (Array.isArray(res.data)) {
|
|
50
|
+
setOptions(
|
|
51
|
+
res.data
|
|
52
|
+
.map((item) => ({
|
|
53
|
+
id: String(item.id ?? ''),
|
|
54
|
+
label: String(item.name ?? item.title ?? item.slug ?? item.id ?? 'Untitled'),
|
|
55
|
+
}))
|
|
56
|
+
.filter((option) => option.id),
|
|
57
|
+
)
|
|
58
|
+
} else {
|
|
59
|
+
setFailed(true)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.catch(() => {
|
|
63
|
+
if (!cancelled) setFailed(true)
|
|
64
|
+
})
|
|
65
|
+
.finally(() => {
|
|
66
|
+
if (!cancelled) setLoading(false)
|
|
67
|
+
})
|
|
68
|
+
return () => {
|
|
69
|
+
cancelled = true
|
|
70
|
+
}
|
|
71
|
+
}, [relationTo])
|
|
72
|
+
|
|
73
|
+
const current = (value as string) ?? ''
|
|
74
|
+
const noun = relationTo === 'forms' ? 'a form' : 'an item'
|
|
75
|
+
|
|
76
|
+
if (failed) {
|
|
77
|
+
return (
|
|
78
|
+
<div>
|
|
79
|
+
<label className={LABEL_CLASS}>{label}</label>
|
|
80
|
+
<input
|
|
81
|
+
type="text"
|
|
82
|
+
value={current}
|
|
83
|
+
onChange={(e) => onChange(e.target.value || undefined)}
|
|
84
|
+
placeholder={`Enter ${relationTo} id…`}
|
|
85
|
+
className={INPUT_CLASS}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div>
|
|
93
|
+
<label className={LABEL_CLASS}>{label}</label>
|
|
94
|
+
<select
|
|
95
|
+
value={current}
|
|
96
|
+
onChange={(e) => onChange(e.target.value || undefined)}
|
|
97
|
+
disabled={loading}
|
|
98
|
+
aria-label={label}
|
|
99
|
+
className={INPUT_CLASS}
|
|
100
|
+
>
|
|
101
|
+
<option value="">{loading ? 'Loading…' : `Select ${noun}…`}</option>
|
|
102
|
+
{options.map((option) => (
|
|
103
|
+
<option key={option.id} value={option.id}>
|
|
104
|
+
{option.label}
|
|
105
|
+
</option>
|
|
106
|
+
))}
|
|
107
|
+
</select>
|
|
108
|
+
{!loading && options.length === 0 && (
|
|
109
|
+
<p className="text-muted-foreground mt-1 text-xs">No {relationTo} found.</p>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -1,17 +1,76 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
3
4
|
import { FileText, Mail, Send } from 'lucide-react'
|
|
5
|
+
import { cmsApi } from '../../../lib/api.js'
|
|
4
6
|
import type { BlockPreviewProps } from './index.js'
|
|
5
7
|
|
|
8
|
+
interface PreviewField {
|
|
9
|
+
key: string
|
|
10
|
+
label: string
|
|
11
|
+
type: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PreviewForm {
|
|
15
|
+
name: string
|
|
16
|
+
fields: PreviewField[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Canvas preview for the form block. When a form is linked we fetch its real
|
|
21
|
+
* name + fields so the editor sees what will render; otherwise we fall back to
|
|
22
|
+
* a generic skeleton. Network failures degrade to the skeleton rather than
|
|
23
|
+
* breaking the canvas.
|
|
24
|
+
*/
|
|
6
25
|
export function FormPreview({ data, variant = 'contact' }: BlockPreviewProps) {
|
|
7
26
|
const formId = (data.formId as string) || ''
|
|
27
|
+
const [form, setForm] = useState<PreviewForm | null>(null)
|
|
28
|
+
const [loading, setLoading] = useState(false)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!formId) {
|
|
32
|
+
setForm(null)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
let cancelled = false
|
|
36
|
+
setLoading(true)
|
|
37
|
+
cmsApi<{ name?: string; fields?: PreviewField[] }>(`/forms/${encodeURIComponent(formId)}`)
|
|
38
|
+
.then((res) => {
|
|
39
|
+
if (cancelled) return
|
|
40
|
+
if (res.data?.name) {
|
|
41
|
+
setForm({ name: res.data.name, fields: res.data.fields ?? [] })
|
|
42
|
+
} else {
|
|
43
|
+
setForm(null)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
.finally(() => {
|
|
47
|
+
if (!cancelled) setLoading(false)
|
|
48
|
+
})
|
|
49
|
+
return () => {
|
|
50
|
+
cancelled = true
|
|
51
|
+
}
|
|
52
|
+
}, [formId])
|
|
53
|
+
|
|
54
|
+
if (!formId) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="border-border bg-muted/30 rounded-md border border-dashed p-6 text-center">
|
|
57
|
+
<FileText size={20} className="text-muted-foreground mx-auto" />
|
|
58
|
+
<p className="text-muted-foreground mt-2 text-sm">No form selected</p>
|
|
59
|
+
<p className="text-muted-foreground/70 text-xs">Pick a form in the block settings</p>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const heading = form?.name ?? (loading ? 'Loading form…' : 'Contact Form')
|
|
65
|
+
const fields = form?.fields ?? []
|
|
8
66
|
|
|
9
67
|
if (variant === 'newsletter') {
|
|
10
68
|
return (
|
|
11
69
|
<div className="border-border rounded-md border p-6 text-center">
|
|
12
70
|
<Mail size={24} className="text-muted-foreground mx-auto" />
|
|
13
|
-
<p className="text-foreground mt-3 text-sm font-medium">
|
|
14
|
-
|
|
71
|
+
<p className="text-foreground mt-3 text-sm font-medium">
|
|
72
|
+
{form?.name ?? 'Newsletter Signup'}
|
|
73
|
+
</p>
|
|
15
74
|
<div className="mx-auto mt-4 flex max-w-xs gap-2">
|
|
16
75
|
<div className="border-border bg-input-background h-9 flex-1 rounded-md border" />
|
|
17
76
|
<div className="bg-primary flex h-9 items-center rounded-md px-3">
|
|
@@ -22,42 +81,35 @@ export function FormPreview({ data, variant = 'contact' }: BlockPreviewProps) {
|
|
|
22
81
|
)
|
|
23
82
|
}
|
|
24
83
|
|
|
25
|
-
if (variant === 'custom') {
|
|
26
|
-
return (
|
|
27
|
-
<div className="border-border rounded-md border p-5">
|
|
28
|
-
<div className="flex items-center gap-2">
|
|
29
|
-
<FileText size={16} className="text-muted-foreground" />
|
|
30
|
-
<p className="text-foreground text-sm font-medium">
|
|
31
|
-
{formId ? `Form: ${formId}` : 'Custom Form'}
|
|
32
|
-
</p>
|
|
33
|
-
</div>
|
|
34
|
-
<div className="mt-3 flex flex-col gap-2">
|
|
35
|
-
<div className="border-border bg-input-background h-8 rounded-md border" />
|
|
36
|
-
<div className="border-border bg-input-background h-8 rounded-md border" />
|
|
37
|
-
<div className="border-border bg-input-background h-20 rounded-md border" />
|
|
38
|
-
</div>
|
|
39
|
-
<div className="mt-3">
|
|
40
|
-
<span className="bg-primary text-primary-foreground inline-block rounded-md px-4 py-2 text-sm">
|
|
41
|
-
Submit
|
|
42
|
-
</span>
|
|
43
|
-
</div>
|
|
44
|
-
</div>
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
84
|
return (
|
|
49
85
|
<div className="border-border rounded-md border p-5">
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
86
|
+
<div className="flex items-center gap-2">
|
|
87
|
+
<FileText size={16} className="text-muted-foreground" />
|
|
88
|
+
<p className="text-foreground text-sm font-medium">{heading}</p>
|
|
89
|
+
</div>
|
|
90
|
+
<div className="mt-3 flex flex-col gap-2">
|
|
91
|
+
{fields.length > 0 ? (
|
|
92
|
+
fields.slice(0, 5).map((field) => (
|
|
93
|
+
<div key={field.key}>
|
|
94
|
+
<p className="text-muted-foreground mb-1 text-xs">{field.label || field.key}</p>
|
|
95
|
+
<div
|
|
96
|
+
className={`border-border bg-input-background rounded-md border ${
|
|
97
|
+
field.type === 'textarea' ? 'h-16' : 'h-8'
|
|
98
|
+
}`}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
))
|
|
102
|
+
) : (
|
|
103
|
+
<>
|
|
104
|
+
<div className="border-border bg-input-background h-8 rounded-md border" />
|
|
105
|
+
<div className="border-border bg-input-background h-8 rounded-md border" />
|
|
106
|
+
<div className="border-border bg-input-background h-20 rounded-md border" />
|
|
107
|
+
</>
|
|
108
|
+
)}
|
|
55
109
|
</div>
|
|
56
|
-
<div className="border-border bg-input-background mt-2 h-8 rounded-md border" />
|
|
57
|
-
<div className="border-border bg-input-background mt-2 h-20 rounded-md border" />
|
|
58
110
|
<div className="mt-3">
|
|
59
111
|
<span className="bg-primary text-primary-foreground inline-block rounded-md px-4 py-2 text-sm">
|
|
60
|
-
Send Message
|
|
112
|
+
{variant === 'custom' ? 'Submit' : 'Send Message'}
|
|
61
113
|
</span>
|
|
62
114
|
</div>
|
|
63
115
|
</div>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react'
|
|
4
|
-
import { Box, Type, Image, Layout, Star } from 'lucide-react'
|
|
5
4
|
import type { BlockNode } from '@actuate-media/cms-core'
|
|
5
|
+
import { blockRenderers, FallbackPreview } from '../block-renderers/index.js'
|
|
6
6
|
|
|
7
7
|
export interface BlockRendererProps {
|
|
8
8
|
node: BlockNode
|
|
@@ -10,13 +10,6 @@ export interface BlockRendererProps {
|
|
|
10
10
|
onSelectNode: (id: string | null) => void
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const BLOCK_ICONS: Record<string, typeof Box> = {
|
|
14
|
-
hero: Star,
|
|
15
|
-
text: Type,
|
|
16
|
-
image: Image,
|
|
17
|
-
layout: Layout,
|
|
18
|
-
}
|
|
19
|
-
|
|
20
13
|
function getBlockLabel(blockType: string): string {
|
|
21
14
|
return blockType.charAt(0).toUpperCase() + blockType.slice(1)
|
|
22
15
|
}
|
|
@@ -26,7 +19,7 @@ export function BlockRenderer({ node, selectedNodeId, onSelectNode }: BlockRende
|
|
|
26
19
|
const isSelected = selectedNodeId === node.id
|
|
27
20
|
const blockType = node.settings.blockType
|
|
28
21
|
const label = getBlockLabel(blockType)
|
|
29
|
-
const
|
|
22
|
+
const Preview = blockRenderers[blockType]
|
|
30
23
|
|
|
31
24
|
const handleClick = (e: React.MouseEvent) => {
|
|
32
25
|
e.stopPropagation()
|
|
@@ -36,6 +29,7 @@ export function BlockRenderer({ node, selectedNodeId, onSelectNode }: BlockRende
|
|
|
36
29
|
return (
|
|
37
30
|
<div
|
|
38
31
|
data-node-id={node.id}
|
|
32
|
+
data-block-type={blockType}
|
|
39
33
|
className={`relative min-h-[48px] transition-shadow ${
|
|
40
34
|
isSelected ? 'ring-primary ring-2 ring-offset-2' : hovered ? 'ring-primary/50 ring-1' : ''
|
|
41
35
|
}`}
|
|
@@ -49,9 +43,13 @@ export function BlockRenderer({ node, selectedNodeId, onSelectNode }: BlockRende
|
|
|
49
43
|
</span>
|
|
50
44
|
)}
|
|
51
45
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
{/* Previews are display-only on the canvas; the wrapper owns selection. */}
|
|
47
|
+
<div className="pointer-events-none">
|
|
48
|
+
{Preview ? (
|
|
49
|
+
<Preview data={node.data} variant={node.settings.variant} />
|
|
50
|
+
) : (
|
|
51
|
+
<FallbackPreview blockType={blockType} data={node.data} />
|
|
52
|
+
)}
|
|
55
53
|
</div>
|
|
56
54
|
</div>
|
|
57
55
|
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, Info } from 'lucide-react'
|
|
4
|
+
import { useGeneralSettings } from './useGeneralSettings.js'
|
|
5
|
+
import { SettingsSaveBar, SettingsValidationSummary } from './components.js'
|
|
6
|
+
import { SiteIndexStatusCard } from './SiteIndexStatusCard.js'
|
|
7
|
+
import { SiteInformationCard } from './SiteInformationCard.js'
|
|
8
|
+
import { LanguageRegionCard } from './LanguageRegionCard.js'
|
|
9
|
+
import { SeoRobotsDefaultsCard } from './SeoRobotsDefaultsCard.js'
|
|
10
|
+
|
|
11
|
+
export interface GeneralSettingsTabProps {
|
|
12
|
+
onNavigate?: (path: string) => void
|
|
13
|
+
/** Current user's role. Only ADMIN can persist changes. */
|
|
14
|
+
role?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function GeneralSettingsTab({ onNavigate, role }: GeneralSettingsTabProps) {
|
|
18
|
+
const canEdit = role === 'ADMIN'
|
|
19
|
+
const settings = useGeneralSettings(canEdit)
|
|
20
|
+
|
|
21
|
+
const runAudit = () => onNavigate?.('/seo?tab=audit')
|
|
22
|
+
|
|
23
|
+
if (settings.loading) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="space-y-4" aria-busy="true" aria-live="polite">
|
|
26
|
+
<span className="sr-only">Loading settings…</span>
|
|
27
|
+
{[0, 1, 2, 3].map((i) => (
|
|
28
|
+
<div key={i} className="border-border bg-card rounded-lg border p-4">
|
|
29
|
+
<div className="bg-muted mb-4 h-4 w-40 animate-pulse rounded" />
|
|
30
|
+
<div className="space-y-3">
|
|
31
|
+
<div className="bg-muted h-9 w-full animate-pulse rounded" />
|
|
32
|
+
<div className="bg-muted h-9 w-full animate-pulse rounded" />
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="space-y-4">
|
|
42
|
+
{settings.loadError && (
|
|
43
|
+
<div
|
|
44
|
+
role="alert"
|
|
45
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
|
|
46
|
+
>
|
|
47
|
+
<AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
|
|
48
|
+
<span className="flex-1">{settings.loadError}</span>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={settings.refetch}
|
|
52
|
+
className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
|
|
53
|
+
>
|
|
54
|
+
Retry
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
{!canEdit && (
|
|
60
|
+
<div className="border-border bg-muted text-muted-foreground flex items-center gap-2 rounded-lg border p-3 text-sm">
|
|
61
|
+
<Info size={16} className="shrink-0" aria-hidden="true" />
|
|
62
|
+
You have read-only access to settings. Only administrators can make changes.
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
<SettingsValidationSummary issues={settings.issues} />
|
|
67
|
+
|
|
68
|
+
<SiteIndexStatusCard
|
|
69
|
+
status={settings.indexStatus}
|
|
70
|
+
loading={settings.loading}
|
|
71
|
+
onNavigate={onNavigate}
|
|
72
|
+
onRunAudit={runAudit}
|
|
73
|
+
/>
|
|
74
|
+
|
|
75
|
+
<SiteInformationCard
|
|
76
|
+
form={settings.form}
|
|
77
|
+
setField={settings.setField}
|
|
78
|
+
sources={settings.sources}
|
|
79
|
+
issues={settings.issues}
|
|
80
|
+
canEdit={canEdit}
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
<LanguageRegionCard
|
|
84
|
+
form={settings.form}
|
|
85
|
+
setField={settings.setField}
|
|
86
|
+
issues={settings.issues}
|
|
87
|
+
canEdit={canEdit}
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
<SeoRobotsDefaultsCard
|
|
91
|
+
form={settings.form}
|
|
92
|
+
setField={settings.setField}
|
|
93
|
+
isProduction={settings.isProduction}
|
|
94
|
+
indexStatus={settings.indexStatus}
|
|
95
|
+
canEdit={canEdit}
|
|
96
|
+
onNavigate={onNavigate}
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
{settings.saveState === 'error' && settings.saveError && (
|
|
100
|
+
<div
|
|
101
|
+
role="alert"
|
|
102
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
|
|
103
|
+
>
|
|
104
|
+
<AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
|
|
105
|
+
<span className="flex-1">{settings.saveError}</span>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={settings.save}
|
|
109
|
+
className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
|
|
110
|
+
>
|
|
111
|
+
Retry
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<SettingsSaveBar
|
|
117
|
+
dirty={settings.dirty}
|
|
118
|
+
canEdit={canEdit}
|
|
119
|
+
hasErrors={settings.hasErrors}
|
|
120
|
+
saveState={settings.saveState}
|
|
121
|
+
onSave={settings.save}
|
|
122
|
+
onReset={settings.reset}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|