@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.
- package/CHANGELOG.md +16 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +29 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/hooks/useIdleLogout.d.ts +18 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +66 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +3 -3
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +36 -7
- package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/hooks/useIdleLogout.ts +77 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/post-types-service.ts +129 -0
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Forms.tsx +154 -126
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +23 -2
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- package/src/components/forms/FormSchemaDrawer.tsx +0 -192
- 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'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
|
|
18
|
-
*
|
|
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:
|
|
123
|
+
{/* Main click target: open the Post Type editor. */}
|
|
123
124
|
<button
|
|
124
125
|
type="button"
|
|
125
126
|
onClick={onClick}
|
|
126
|
-
aria-label={`
|
|
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">
|