@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.
Files changed (79) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +3 -3
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/views/general-settings.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/general-settings.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/general-settings.render.test.js +105 -0
  7. package/dist/__tests__/views/general-settings.render.test.js.map +1 -0
  8. package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/page-builder-block-renderer.render.test.js +48 -0
  11. package/dist/__tests__/views/page-builder-block-renderer.render.test.js.map +1 -0
  12. package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/page-builder-form-picker.render.test.js +44 -0
  15. package/dist/__tests__/views/page-builder-form-picker.render.test.js.map +1 -0
  16. package/dist/actuate-admin.css +1 -1
  17. package/dist/views/Settings.d.ts +3 -1
  18. package/dist/views/Settings.d.ts.map +1 -1
  19. package/dist/views/Settings.js +9 -28
  20. package/dist/views/Settings.js.map +1 -1
  21. package/dist/views/page-builder/BlockEditor.d.ts.map +1 -1
  22. package/dist/views/page-builder/BlockEditor.js +2 -1
  23. package/dist/views/page-builder/BlockEditor.js.map +1 -1
  24. package/dist/views/page-builder/RelationshipField.d.ts +15 -0
  25. package/dist/views/page-builder/RelationshipField.d.ts.map +1 -0
  26. package/dist/views/page-builder/RelationshipField.js +65 -0
  27. package/dist/views/page-builder/RelationshipField.js.map +1 -0
  28. package/dist/views/page-builder/block-renderers/FormPreview.d.ts +6 -0
  29. package/dist/views/page-builder/block-renderers/FormPreview.d.ts.map +1 -1
  30. package/dist/views/page-builder/block-renderers/FormPreview.js +44 -6
  31. package/dist/views/page-builder/block-renderers/FormPreview.js.map +1 -1
  32. package/dist/views/page-builder/canvas/BlockRenderer.d.ts.map +1 -1
  33. package/dist/views/page-builder/canvas/BlockRenderer.js +3 -9
  34. package/dist/views/page-builder/canvas/BlockRenderer.js.map +1 -1
  35. package/dist/views/settings/GeneralSettingsTab.d.ts +7 -0
  36. package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -0
  37. package/dist/views/settings/GeneralSettingsTab.js +19 -0
  38. package/dist/views/settings/GeneralSettingsTab.js.map +1 -0
  39. package/dist/views/settings/LanguageRegionCard.d.ts +10 -0
  40. package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -0
  41. package/dist/views/settings/LanguageRegionCard.js +48 -0
  42. package/dist/views/settings/LanguageRegionCard.js.map +1 -0
  43. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts +12 -0
  44. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -0
  45. package/dist/views/settings/SeoRobotsDefaultsCard.js +45 -0
  46. package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -0
  47. package/dist/views/settings/SiteIndexStatusCard.d.ts +12 -0
  48. package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -0
  49. package/dist/views/settings/SiteIndexStatusCard.js +51 -0
  50. package/dist/views/settings/SiteIndexStatusCard.js.map +1 -0
  51. package/dist/views/settings/SiteInformationCard.d.ts +11 -0
  52. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -0
  53. package/dist/views/settings/SiteInformationCard.js +21 -0
  54. package/dist/views/settings/SiteInformationCard.js.map +1 -0
  55. package/dist/views/settings/components.d.ts +38 -0
  56. package/dist/views/settings/components.d.ts.map +1 -0
  57. package/dist/views/settings/components.js +43 -0
  58. package/dist/views/settings/components.js.map +1 -0
  59. package/dist/views/settings/useGeneralSettings.d.ts +79 -0
  60. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -0
  61. package/dist/views/settings/useGeneralSettings.js +286 -0
  62. package/dist/views/settings/useGeneralSettings.js.map +1 -0
  63. package/package.json +3 -3
  64. package/src/AdminRoot.tsx +5 -3
  65. package/src/__tests__/views/general-settings.render.test.tsx +135 -0
  66. package/src/__tests__/views/page-builder-block-renderer.render.test.tsx +66 -0
  67. package/src/__tests__/views/page-builder-form-picker.render.test.tsx +54 -0
  68. package/src/views/Settings.tsx +17 -120
  69. package/src/views/page-builder/BlockEditor.tsx +7 -10
  70. package/src/views/page-builder/RelationshipField.tsx +113 -0
  71. package/src/views/page-builder/block-renderers/FormPreview.tsx +85 -33
  72. package/src/views/page-builder/canvas/BlockRenderer.tsx +10 -12
  73. package/src/views/settings/GeneralSettingsTab.tsx +126 -0
  74. package/src/views/settings/LanguageRegionCard.tsx +119 -0
  75. package/src/views/settings/SeoRobotsDefaultsCard.tsx +149 -0
  76. package/src/views/settings/SiteIndexStatusCard.tsx +184 -0
  77. package/src/views/settings/SiteInformationCard.tsx +122 -0
  78. package/src/views/settings/components.tsx +198 -0
  79. package/src/views/settings/useGeneralSettings.ts +376 -0
@@ -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({ config, onNavigate, initialTab = 'general' }: SettingsProps = {}) {
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
- siteTitle,
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
- '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'
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" className="space-y-4">
287
- <div className="rounded-lg border border-gray-200 bg-white p-4">
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
- <div>
301
- <label className={LABEL_CLASS}>{label}</label>
302
- <input
303
- type="text"
304
- value={(value as string) ?? ''}
305
- onChange={(e) => onChange(e.target.value)}
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">Newsletter Signup</p>
14
- {formId && <p className="text-muted-foreground mt-1 text-xs">Form: {formId}</p>}
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
- <p className="text-foreground text-sm font-medium">Contact Form</p>
51
- {formId && <p className="text-muted-foreground mt-1 text-xs">Form: {formId}</p>}
52
- <div className="mt-3 grid grid-cols-2 gap-2">
53
- <div className="border-border bg-input-background h-8 rounded-md border" />
54
- <div className="border-border bg-input-background h-8 rounded-md border" />
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 Icon = BLOCK_ICONS[blockType] ?? Box
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
- <div className="bg-muted/30 border-border flex items-center justify-center gap-2 rounded-md border border-dashed p-4">
53
- <Icon size={16} className="text-muted-foreground" />
54
- <span className="text-muted-foreground text-sm">{label}</span>
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
+ }