@actuate-media/cms-admin 0.67.0 → 0.68.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 (146) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +29 -5
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
  6. package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
  7. package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
  8. package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
  9. package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
  10. package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/post-types-service.test.js +88 -0
  12. package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
  13. package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
  14. package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
  15. package/dist/__tests__/views/forms-list.render.test.js +25 -6
  16. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  17. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
  18. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
  19. package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
  20. package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
  21. package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
  22. package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/views/page-templates.render.test.js +105 -0
  24. package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
  25. package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
  26. package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
  27. package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
  28. package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
  29. package/dist/__tests__/views/security-settings.render.test.js +85 -4
  30. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  31. package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
  34. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
  35. package/dist/actuate-admin.css +1 -1
  36. package/dist/components/collections/EntryDetailPane.d.ts +10 -0
  37. package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
  38. package/dist/components/collections/EntryDetailPane.js +92 -0
  39. package/dist/components/collections/EntryDetailPane.js.map +1 -0
  40. package/dist/components/forms/FieldsPanel.js +1 -1
  41. package/dist/components/forms/FieldsPanel.js.map +1 -1
  42. package/dist/components/forms/FormSchemaPane.d.ts +8 -0
  43. package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
  44. package/dist/components/forms/FormSchemaPane.js +95 -0
  45. package/dist/components/forms/FormSchemaPane.js.map +1 -0
  46. package/dist/components/seo/SeoEditorPane.d.ts +9 -0
  47. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
  48. package/dist/components/seo/SeoEditorPane.js +175 -0
  49. package/dist/components/seo/SeoEditorPane.js.map +1 -0
  50. package/dist/components/ui/Badge.d.ts +1 -1
  51. package/dist/components/ui/Button.d.ts +1 -1
  52. package/dist/components/ui/StatusBadge.d.ts +1 -1
  53. package/dist/hooks/useIdleLogout.d.ts +18 -0
  54. package/dist/hooks/useIdleLogout.d.ts.map +1 -0
  55. package/dist/hooks/useIdleLogout.js +66 -0
  56. package/dist/hooks/useIdleLogout.js.map +1 -0
  57. package/dist/lib/api.d.ts +1 -0
  58. package/dist/lib/api.d.ts.map +1 -1
  59. package/dist/lib/api.js +7 -1
  60. package/dist/lib/api.js.map +1 -1
  61. package/dist/lib/post-types-service.d.ts +44 -0
  62. package/dist/lib/post-types-service.d.ts.map +1 -0
  63. package/dist/lib/post-types-service.js +83 -0
  64. package/dist/lib/post-types-service.js.map +1 -0
  65. package/dist/views/CollectionList.d.ts.map +1 -1
  66. package/dist/views/CollectionList.js +23 -4
  67. package/dist/views/CollectionList.js.map +1 -1
  68. package/dist/views/Forms.d.ts.map +1 -1
  69. package/dist/views/Forms.js +25 -21
  70. package/dist/views/Forms.js.map +1 -1
  71. package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
  72. package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
  73. package/dist/views/Posts/PostTypeEditor.js +159 -0
  74. package/dist/views/Posts/PostTypeEditor.js.map +1 -0
  75. package/dist/views/Posts/PostTypesView.d.ts +3 -2
  76. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  77. package/dist/views/Posts/PostTypesView.js +1 -1
  78. package/dist/views/Posts/PostTypesView.js.map +1 -1
  79. package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
  80. package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
  81. package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
  82. package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
  83. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  84. package/dist/views/page-builder/PageBuilder.js +51 -2
  85. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  86. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  87. package/dist/views/page-builder/PageTemplates.js +113 -6
  88. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  89. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  90. package/dist/views/seo/ContentTab.js +22 -6
  91. package/dist/views/seo/ContentTab.js.map +1 -1
  92. package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
  93. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
  94. package/dist/views/settings/AccessProtectionCard.js +13 -10
  95. package/dist/views/settings/AccessProtectionCard.js.map +1 -1
  96. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  97. package/dist/views/settings/SecurityTab.js +3 -3
  98. package/dist/views/settings/SecurityTab.js.map +1 -1
  99. package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
  100. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
  101. package/dist/views/settings/SessionPolicyCard.js +12 -5
  102. package/dist/views/settings/SessionPolicyCard.js.map +1 -1
  103. package/dist/views/settings/useSecuritySettings.d.ts +13 -1
  104. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
  105. package/dist/views/settings/useSecuritySettings.js +47 -4
  106. package/dist/views/settings/useSecuritySettings.js.map +1 -1
  107. package/package.json +2 -2
  108. package/src/AdminRoot.tsx +36 -7
  109. package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
  110. package/src/__tests__/lib/post-types-service.test.ts +112 -0
  111. package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
  112. package/src/__tests__/views/forms-list.render.test.tsx +34 -6
  113. package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
  114. package/src/__tests__/views/page-templates.render.test.tsx +142 -0
  115. package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
  116. package/src/__tests__/views/security-settings.render.test.tsx +111 -4
  117. package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
  118. package/src/components/collections/EntryDetailPane.tsx +182 -0
  119. package/src/components/forms/FieldsPanel.tsx +3 -1
  120. package/src/components/forms/FormSchemaPane.tsx +192 -0
  121. package/src/components/seo/SeoEditorPane.tsx +533 -0
  122. package/src/hooks/useIdleLogout.ts +77 -0
  123. package/src/lib/api.ts +8 -2
  124. package/src/lib/post-types-service.ts +129 -0
  125. package/src/views/CollectionList.tsx +158 -110
  126. package/src/views/Forms.tsx +154 -126
  127. package/src/views/Posts/PostTypeEditor.tsx +568 -0
  128. package/src/views/Posts/PostTypesView.tsx +5 -4
  129. package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
  130. package/src/views/page-builder/PageBuilder.tsx +69 -2
  131. package/src/views/page-builder/PageTemplates.tsx +340 -57
  132. package/src/views/seo/ContentTab.tsx +113 -80
  133. package/src/views/settings/AccessProtectionCard.tsx +83 -53
  134. package/src/views/settings/SecurityTab.tsx +23 -2
  135. package/src/views/settings/SessionPolicyCard.tsx +45 -9
  136. package/src/views/settings/useSecuritySettings.ts +78 -22
  137. package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
  138. package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
  139. package/dist/components/forms/FormSchemaDrawer.js +0 -99
  140. package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
  141. package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
  142. package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
  143. package/dist/components/seo/SeoEditorDrawer.js +0 -172
  144. package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
  145. package/src/components/forms/FormSchemaDrawer.tsx +0 -192
  146. package/src/components/seo/SeoEditorDrawer.tsx +0 -529
@@ -0,0 +1,568 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import {
5
+ Check,
6
+ ChevronRight,
7
+ Copy,
8
+ ExternalLink,
9
+ FileQuestion,
10
+ LayoutTemplate,
11
+ Lock,
12
+ Plus,
13
+ } from 'lucide-react'
14
+ import { toast } from 'sonner'
15
+ import { fetchPosts } from '../../lib/posts-service.js'
16
+ import {
17
+ fetchPostTypeDetail,
18
+ fieldKeyFromName,
19
+ fieldTypeLabel,
20
+ humanizeFieldName,
21
+ renderFieldSnippet,
22
+ type PostTypeDetail,
23
+ } from '../../lib/post-types-service.js'
24
+ import { paletteFor } from '../../components/posts/badges.js'
25
+ import { resolvePostTypeIcon } from '../../components/posts/icons.js'
26
+ import { Toggle } from '../../components/ui/Toggle.js'
27
+
28
+ /**
29
+ * /posts/types/:slug/edit — Post Type editor (design `TypeEditor`).
30
+ *
31
+ * Layout matches the design split: toolbar, 340px identity panel, field
32
+ * builder. Collections are file-defined in `actuate.config.ts`, so identity
33
+ * and the field list are the *live schema* rendered read-only, and "Add
34
+ * Field" emits a paste-ready config snippet instead of mutating a schema
35
+ * the server can't persist (same model as the New Post Type flow).
36
+ */
37
+
38
+ interface PostTypeEditorProps {
39
+ slug: string
40
+ onNavigate: (path: string) => void
41
+ }
42
+
43
+ const ADDABLE_FIELD_TYPES: { type: string; desc: string }[] = [
44
+ { type: 'text', desc: 'Single line' },
45
+ { type: 'richText', desc: 'WYSIWYG' },
46
+ { type: 'number', desc: 'Int / decimal' },
47
+ { type: 'date', desc: 'Date & time' },
48
+ { type: 'boolean', desc: 'True / false' },
49
+ { type: 'url', desc: 'Web address' },
50
+ { type: 'email', desc: 'Email address' },
51
+ { type: 'select', desc: 'Dropdown' },
52
+ { type: 'media', desc: 'Image / file' },
53
+ { type: 'relationship', desc: 'Link to type' },
54
+ { type: 'json', desc: 'Raw data' },
55
+ ]
56
+
57
+ export function PostTypeEditor({ slug, onNavigate }: PostTypeEditorProps) {
58
+ const [detail, setDetail] = useState<PostTypeDetail | null>(null)
59
+ const [postCount, setPostCount] = useState<number | null>(null)
60
+ const [loading, setLoading] = useState(true)
61
+ const [error, setError] = useState<string | null>(null)
62
+ const [notFound, setNotFound] = useState(false)
63
+ const [refreshToken, setRefreshToken] = useState(0)
64
+
65
+ useEffect(() => {
66
+ let cancelled = false
67
+ setLoading(true)
68
+ setError(null)
69
+ setNotFound(false)
70
+ Promise.all([fetchPostTypeDetail(slug), fetchPosts({ pageSize: 1 }).catch(() => null)])
71
+ .then(([d, postsRes]) => {
72
+ if (cancelled) return
73
+ if (!d) {
74
+ setNotFound(true)
75
+ } else {
76
+ setDetail(d)
77
+ const typeRow = postsRes?.types.find((t) => t.slug === slug)
78
+ setPostCount(typeRow?.postCount ?? null)
79
+ }
80
+ setLoading(false)
81
+ })
82
+ .catch((err: unknown) => {
83
+ if (cancelled) return
84
+ setError(err instanceof Error ? err.message : 'Failed to load post type')
85
+ setLoading(false)
86
+ })
87
+ return () => {
88
+ cancelled = true
89
+ }
90
+ }, [slug, refreshToken])
91
+
92
+ if (loading) return <EditorSkeleton />
93
+
94
+ if (notFound) {
95
+ return (
96
+ <CenteredNotice
97
+ title={`No post type named "${slug}"`}
98
+ body="It may have been removed from actuate.config.ts."
99
+ actionLabel="Back to Post Types"
100
+ onAction={() => onNavigate('/posts/types')}
101
+ />
102
+ )
103
+ }
104
+
105
+ if (error || !detail) {
106
+ return (
107
+ <CenteredNotice
108
+ title={error ?? 'Failed to load post type'}
109
+ actionLabel="Try again"
110
+ onAction={() => setRefreshToken((t) => t + 1)}
111
+ />
112
+ )
113
+ }
114
+
115
+ return (
116
+ <div className="flex h-full flex-col">
117
+ <EditorToolbar detail={detail} postCount={postCount} onNavigate={onNavigate} />
118
+ <div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto p-4 sm:p-6 lg:flex-row lg:items-start lg:overflow-hidden">
119
+ <IdentityPanel detail={detail} />
120
+ <FieldBuilder detail={detail} />
121
+ </div>
122
+ </div>
123
+ )
124
+ }
125
+
126
+ // ─── Toolbar ─────────────────────────────────────────────────────────
127
+
128
+ function EditorToolbar({
129
+ detail,
130
+ postCount,
131
+ onNavigate,
132
+ }: {
133
+ detail: PostTypeDetail
134
+ postCount: number | null
135
+ onNavigate: (path: string) => void
136
+ }) {
137
+ const palette = paletteFor(detail.color)
138
+ return (
139
+ <div className="border-border bg-card flex flex-wrap items-center gap-3 border-b px-4 py-3 sm:px-6">
140
+ <nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1.5 text-sm">
141
+ <button
142
+ type="button"
143
+ onClick={() => onNavigate('/posts/types')}
144
+ className="text-muted-foreground hover:text-foreground transition-colors"
145
+ >
146
+ Post Types
147
+ </button>
148
+ <ChevronRight className="text-muted-foreground h-3.5 w-3.5 shrink-0" aria-hidden />
149
+ <span className="text-foreground truncate font-medium">{detail.plural}</span>
150
+ </nav>
151
+ {postCount !== null && (
152
+ <span
153
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium tabular-nums ${palette.bg} ${palette.text}`}
154
+ >
155
+ {postCount} {postCount === 1 ? 'post' : 'posts'}
156
+ </span>
157
+ )}
158
+ <div className="ml-auto flex items-center gap-2">
159
+ <button
160
+ type="button"
161
+ onClick={() => onNavigate(`/posts/types/${detail.slug}/template`)}
162
+ className="border-border text-foreground hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2"
163
+ >
164
+ <LayoutTemplate className="h-3.5 w-3.5" aria-hidden />
165
+ Edit template
166
+ </button>
167
+ <button
168
+ type="button"
169
+ onClick={() => onNavigate('/posts')}
170
+ className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
171
+ >
172
+ <ExternalLink className="h-3.5 w-3.5" aria-hidden />
173
+ View posts
174
+ </button>
175
+ </div>
176
+ </div>
177
+ )
178
+ }
179
+
180
+ // ─── Identity panel (left, 340px) ────────────────────────────────────
181
+
182
+ function IdentityPanel({ detail }: { detail: PostTypeDetail }) {
183
+ const palette = paletteFor(detail.color)
184
+ const Icon = resolvePostTypeIcon(detail.icon)
185
+ return (
186
+ <aside
187
+ aria-label="Post type identity"
188
+ className="flex w-full shrink-0 flex-col gap-5 lg:max-h-full lg:w-[340px] lg:overflow-y-auto"
189
+ >
190
+ <IdentitySection title="Identity">
191
+ <ReadOnlyField label="Type Name" value={detail.plural} />
192
+ <ReadOnlyField label="API Slug" value={detail.slug} mono />
193
+ <ReadOnlyField
194
+ label="Description"
195
+ value={detail.description ?? 'No description provided.'}
196
+ />
197
+ <p className="text-muted-foreground text-xs">
198
+ Identity and fields are defined in <code className="font-mono">actuate.config.ts</code>{' '}
199
+ under <code className="font-mono">collections.{detail.slug}</code>. Edit the config and
200
+ redeploy to change them.
201
+ </p>
202
+ </IdentitySection>
203
+
204
+ <IdentitySection title="Icon & color">
205
+ <div className="flex items-center gap-3">
206
+ <span
207
+ className={`inline-flex h-10 w-10 items-center justify-center rounded-lg ${palette.bg} ${palette.text}`}
208
+ >
209
+ <Icon className="h-5 w-5" aria-hidden />
210
+ </span>
211
+ <div className="flex flex-col gap-0.5 text-sm">
212
+ <span className="text-foreground font-medium">{detail.icon ?? 'file-text'}</span>
213
+ <span className="text-muted-foreground text-xs">
214
+ Accent: {detail.color ?? 'default'}
215
+ </span>
216
+ </div>
217
+ </div>
218
+ </IdentitySection>
219
+
220
+ <IdentitySection title="API preview">
221
+ <div className="border-border bg-muted flex flex-col gap-1.5 overflow-x-auto rounded-lg border p-3 font-mono text-xs">
222
+ <ApiRow method="GET" path={`/api/cms/collections/${detail.slug}`} />
223
+ <ApiRow method="GET" path={`/api/cms/collections/${detail.slug}/{id}`} />
224
+ <ApiRow method="POST" path={`/api/cms/collections/${detail.slug}`} />
225
+ </div>
226
+ </IdentitySection>
227
+ </aside>
228
+ )
229
+ }
230
+
231
+ function IdentitySection({ title, children }: { title: string; children: React.ReactNode }) {
232
+ return (
233
+ <section className="bg-card border-border flex flex-col gap-3 rounded-xl border p-5 shadow-sm">
234
+ <h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">{title}</h2>
235
+ {children}
236
+ </section>
237
+ )
238
+ }
239
+
240
+ function ReadOnlyField({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
241
+ return (
242
+ <div className="flex flex-col gap-1">
243
+ <span className="text-foreground text-sm font-medium">{label}</span>
244
+ <div
245
+ className={`border-border bg-muted text-foreground rounded-lg border px-3 py-1.5 text-sm ${mono ? 'font-mono' : ''}`}
246
+ >
247
+ {value}
248
+ </div>
249
+ </div>
250
+ )
251
+ }
252
+
253
+ function ApiRow({ method, path }: { method: 'GET' | 'POST'; path: string }) {
254
+ return (
255
+ <div className="flex items-center gap-2 whitespace-nowrap">
256
+ <span
257
+ className={`w-10 shrink-0 font-medium ${method === 'GET' ? 'text-success' : 'text-info'}`}
258
+ >
259
+ {method}
260
+ </span>
261
+ <span className="text-muted-foreground">{path}</span>
262
+ </div>
263
+ )
264
+ }
265
+
266
+ // ─── Field builder (right) ───────────────────────────────────────────
267
+
268
+ function FieldBuilder({ detail }: { detail: PostTypeDetail }) {
269
+ return (
270
+ <div className="flex min-w-0 flex-1 flex-col gap-4 lg:max-h-full lg:overflow-y-auto">
271
+ <div className="flex flex-col gap-0.5">
272
+ <h2 className="text-foreground text-base font-medium">Field Builder</h2>
273
+ <p className="text-muted-foreground text-sm">
274
+ {detail.fields.length} {detail.fields.length === 1 ? 'field' : 'fields'} defined in{' '}
275
+ <code className="font-mono">actuate.config.ts</code>
276
+ </p>
277
+ </div>
278
+ <FieldList detail={detail} />
279
+ <AddFieldCard detail={detail} />
280
+ </div>
281
+ )
282
+ }
283
+
284
+ function FieldList({ detail }: { detail: PostTypeDetail }) {
285
+ return (
286
+ <div className="bg-card border-border rounded-xl border shadow-sm">
287
+ <div className="border-border flex items-center gap-2 border-b px-4 py-3">
288
+ <Lock className="text-muted-foreground h-3.5 w-3.5" aria-hidden />
289
+ <span className="text-foreground text-sm font-medium">Fields</span>
290
+ <span className="bg-muted text-muted-foreground ml-auto rounded-full px-2 py-0.5 text-xs font-medium">
291
+ Defined in code · {detail.fields.length} {detail.fields.length === 1 ? 'field' : 'fields'}
292
+ </span>
293
+ </div>
294
+ {detail.fields.length === 0 ? (
295
+ <p className="text-muted-foreground px-4 py-6 text-center text-sm">
296
+ This type has no fields yet. Add one below.
297
+ </p>
298
+ ) : (
299
+ <ul className="divide-border divide-y">
300
+ {detail.fields.map((f) => (
301
+ <li key={f.name} className="flex items-center gap-3 px-4 py-2.5">
302
+ <Lock className="text-muted-foreground h-3.5 w-3.5 shrink-0" aria-hidden />
303
+ <div className="flex min-w-0 flex-col">
304
+ <span className="text-foreground truncate text-sm font-medium">
305
+ {humanizeFieldName(f.name)}
306
+ </span>
307
+ <span className="text-muted-foreground truncate font-mono text-xs">{f.name}</span>
308
+ </div>
309
+ <span className="border-border text-muted-foreground ml-auto shrink-0 rounded-full border px-2 py-0.5 text-xs">
310
+ {fieldTypeLabel(f.type)}
311
+ </span>
312
+ <span
313
+ className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${
314
+ f.required
315
+ ? 'bg-destructive/10 text-destructive'
316
+ : 'bg-muted text-muted-foreground'
317
+ }`}
318
+ >
319
+ {f.required ? 'Required' : 'Optional'}
320
+ </span>
321
+ </li>
322
+ ))}
323
+ </ul>
324
+ )}
325
+ </div>
326
+ )
327
+ }
328
+
329
+ // ─── Add Field (snippet generator) ───────────────────────────────────
330
+
331
+ function AddFieldCard({ detail }: { detail: PostTypeDetail }) {
332
+ const [open, setOpen] = useState(false)
333
+ const [fieldType, setFieldType] = useState<string | null>(null)
334
+ const [name, setName] = useState('')
335
+ const [helpText, setHelpText] = useState('')
336
+ const [required, setRequired] = useState(false)
337
+ const [copied, setCopied] = useState(false)
338
+
339
+ const key = useMemo(() => fieldKeyFromName(name), [name])
340
+ const existingKeys = useMemo(() => new Set(detail.fields.map((f) => f.name)), [detail.fields])
341
+ const duplicate = key !== '' && existingKeys.has(key)
342
+
343
+ const snippet = useMemo(() => {
344
+ if (!fieldType || !key) return ''
345
+ return renderFieldSnippet({
346
+ key,
347
+ label: name.trim() || humanizeFieldName(key),
348
+ type: fieldType,
349
+ required,
350
+ helpText,
351
+ })
352
+ }, [fieldType, key, name, required, helpText])
353
+
354
+ const reset = () => {
355
+ setFieldType(null)
356
+ setName('')
357
+ setHelpText('')
358
+ setRequired(false)
359
+ setCopied(false)
360
+ }
361
+
362
+ const copy = async () => {
363
+ try {
364
+ await navigator.clipboard.writeText(snippet)
365
+ setCopied(true)
366
+ toast.success('Field snippet copied — paste it into the fields map')
367
+ setTimeout(() => setCopied(false), 1800)
368
+ } catch {
369
+ toast.error('Copy failed — select the snippet manually')
370
+ }
371
+ }
372
+
373
+ if (!open) {
374
+ return (
375
+ <button
376
+ type="button"
377
+ onClick={() => setOpen(true)}
378
+ className="border-border text-muted-foreground hover:text-foreground hover:border-primary/50 hover:bg-primary/5 focus-visible:ring-ring flex items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-4 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2"
379
+ >
380
+ <Plus className="h-4 w-4" aria-hidden />
381
+ Add Field
382
+ </button>
383
+ )
384
+ }
385
+
386
+ return (
387
+ <div className="bg-card border-border flex flex-col gap-4 rounded-xl border p-5 shadow-sm">
388
+ <div className="flex items-center justify-between">
389
+ <h3 className="text-foreground text-sm font-medium">Add Field</h3>
390
+ <button
391
+ type="button"
392
+ onClick={() => {
393
+ reset()
394
+ setOpen(false)
395
+ }}
396
+ className="text-muted-foreground hover:text-foreground text-sm transition-colors"
397
+ >
398
+ Cancel
399
+ </button>
400
+ </div>
401
+
402
+ {/* Field type picker */}
403
+ <div
404
+ role="radiogroup"
405
+ aria-label="Field type"
406
+ className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-4"
407
+ >
408
+ {ADDABLE_FIELD_TYPES.map((t) => {
409
+ const selected = fieldType === t.type
410
+ return (
411
+ <button
412
+ key={t.type}
413
+ type="button"
414
+ role="radio"
415
+ aria-checked={selected}
416
+ onClick={() => setFieldType(t.type)}
417
+ className={`focus-visible:ring-ring flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition-colors focus:outline-none focus-visible:ring-2 ${
418
+ selected
419
+ ? 'border-primary bg-primary/5'
420
+ : 'border-border hover:border-primary/40 hover:bg-accent'
421
+ }`}
422
+ >
423
+ <span className="text-foreground text-sm font-medium">{fieldTypeLabel(t.type)}</span>
424
+ <span className="text-muted-foreground text-xs">{t.desc}</span>
425
+ </button>
426
+ )
427
+ })}
428
+ </div>
429
+
430
+ {fieldType && (
431
+ <>
432
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
433
+ <div className="flex flex-col gap-1">
434
+ <label htmlFor="af-name" className="text-foreground text-sm font-medium">
435
+ Field Name
436
+ </label>
437
+ <input
438
+ id="af-name"
439
+ value={name}
440
+ onChange={(e) => setName(e.target.value)}
441
+ placeholder="e.g. Client Name"
442
+ className="border-border bg-background text-foreground focus:border-primary focus:ring-primary/30 w-full rounded-lg border px-3 py-1.5 text-sm focus:ring-2 focus:outline-none"
443
+ />
444
+ </div>
445
+ <div className="flex flex-col gap-1">
446
+ <label htmlFor="af-key" className="text-foreground text-sm font-medium">
447
+ Field Key
448
+ </label>
449
+ <input
450
+ id="af-key"
451
+ value={key}
452
+ readOnly
453
+ aria-describedby={duplicate ? 'af-key-error' : undefined}
454
+ className="border-border bg-muted text-muted-foreground w-full rounded-lg border px-3 py-1.5 font-mono text-sm focus:outline-none"
455
+ />
456
+ {duplicate && (
457
+ <span id="af-key-error" className="text-destructive text-xs">
458
+ A field named <code className="font-mono">{key}</code> already exists on this
459
+ type.
460
+ </span>
461
+ )}
462
+ </div>
463
+ </div>
464
+ <div className="flex flex-col gap-1">
465
+ <label htmlFor="af-help" className="text-foreground text-sm font-medium">
466
+ Help Text (optional)
467
+ </label>
468
+ <input
469
+ id="af-help"
470
+ value={helpText}
471
+ onChange={(e) => setHelpText(e.target.value)}
472
+ placeholder="Shown to editors as a hint"
473
+ className="border-border bg-background text-foreground focus:border-primary focus:ring-primary/30 w-full rounded-lg border px-3 py-1.5 text-sm focus:ring-2 focus:outline-none"
474
+ />
475
+ </div>
476
+ <div className="flex items-center gap-2">
477
+ <Toggle
478
+ id="af-required"
479
+ checked={required}
480
+ onCheckedChange={setRequired}
481
+ aria-labelledby="af-required-label"
482
+ />
483
+ <label id="af-required-label" htmlFor="af-required" className="text-foreground text-sm">
484
+ Required field
485
+ </label>
486
+ </div>
487
+
488
+ {snippet && !duplicate && (
489
+ <div className="flex flex-col gap-2">
490
+ <div className="flex items-center justify-between">
491
+ <span className="text-foreground text-sm font-medium">
492
+ Add to <code className="font-mono">collections.{detail.slug}.fields</code>
493
+ </span>
494
+ <button
495
+ type="button"
496
+ onClick={copy}
497
+ className="border-border bg-background text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors"
498
+ >
499
+ {copied ? (
500
+ <Check className="h-3.5 w-3.5" aria-hidden />
501
+ ) : (
502
+ <Copy className="h-3.5 w-3.5" aria-hidden />
503
+ )}
504
+ {copied ? 'Copied' : 'Copy snippet'}
505
+ </button>
506
+ </div>
507
+ <pre className="border-border bg-muted text-foreground overflow-x-auto rounded-lg border p-3 text-xs leading-relaxed">
508
+ <code>{snippet}</code>
509
+ </pre>
510
+ <p className="text-muted-foreground text-xs">
511
+ Paste the line into the type&apos;s <code className="font-mono">fields</code> map in{' '}
512
+ <code className="font-mono">actuate.config.ts</code> and redeploy — the field
513
+ appears in the editor automatically.
514
+ </p>
515
+ </div>
516
+ )}
517
+ </>
518
+ )}
519
+ </div>
520
+ )
521
+ }
522
+
523
+ // ─── Loading / notice states ─────────────────────────────────────────
524
+
525
+ function EditorSkeleton() {
526
+ return (
527
+ <div className="flex h-full flex-col">
528
+ <div className="border-border bg-card border-b px-4 py-3 sm:px-6">
529
+ <div className="bg-muted h-5 w-48 animate-pulse rounded" />
530
+ </div>
531
+ <div className="flex flex-col gap-6 p-4 sm:p-6 lg:flex-row">
532
+ <div className="flex w-full flex-col gap-4 lg:w-[340px]">
533
+ {Array.from({ length: 3 }).map((_, i) => (
534
+ <div key={i} className="bg-card border-border h-32 animate-pulse rounded-xl border" />
535
+ ))}
536
+ </div>
537
+ <div className="bg-card border-border h-64 flex-1 animate-pulse rounded-xl border" />
538
+ </div>
539
+ </div>
540
+ )
541
+ }
542
+
543
+ function CenteredNotice({
544
+ title,
545
+ body,
546
+ actionLabel,
547
+ onAction,
548
+ }: {
549
+ title: string
550
+ body?: string
551
+ actionLabel: string
552
+ onAction: () => void
553
+ }) {
554
+ return (
555
+ <div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
556
+ <FileQuestion className="text-muted-foreground h-6 w-6" aria-hidden />
557
+ <div className="text-foreground text-sm font-medium">{title}</div>
558
+ {body && <p className="text-muted-foreground text-sm">{body}</p>}
559
+ <button
560
+ type="button"
561
+ onClick={onAction}
562
+ className="text-primary mt-2 text-sm font-medium hover:underline"
563
+ >
564
+ {actionLabel}
565
+ </button>
566
+ </div>
567
+ )
568
+ }
@@ -14,8 +14,9 @@ import { resolvePostTypeIcon } from '../../components/posts/icons.js'
14
14
  * - title + description
15
15
  * - footer row: post count · field count · URL base
16
16
  *
17
- * Clicking a card opens that type's settings (`/posts/types/:slug/edit`);
18
- * each card also has an "Edit Template" action (`/posts/types/:slug/template`)
17
+ * Clicking a card opens the Post Type editor (`/posts/types/:slug/edit`)
18
+ * identity, live field schema, and the Add Field snippet generator. Each
19
+ * card also has an "Edit Template" action (`/posts/types/:slug/template`)
19
20
  * for the type's default layout. The dashed "+ New Post Type" card routes to
20
21
  * `/posts/types/new`, where users get instructions for adding a new collection
21
22
  * to the config file.
@@ -119,11 +120,11 @@ function PostTypeCard({
119
120
  return (
120
121
  <div className="bg-card border-border relative flex flex-col gap-3 overflow-hidden rounded-xl border p-5 shadow-sm transition-shadow hover:shadow-md">
121
122
  <span aria-hidden className={`absolute inset-x-0 top-0 h-1 ${topBorderClass}`} />
122
- {/* Main click target: manage posts of this type. */}
123
+ {/* Main click target: open the Post Type editor. */}
123
124
  <button
124
125
  type="button"
125
126
  onClick={onClick}
126
- aria-label={`Manage ${type.name} posts`}
127
+ aria-label={`Edit ${type.name} post type`}
127
128
  className="focus-visible:ring-ring -m-1 flex flex-col gap-3 rounded-lg p-1 text-left focus:outline-none focus-visible:ring-2"
128
129
  >
129
130
  <div className="flex items-center gap-3 pt-2">