@actuate-media/cms-admin 0.46.0 → 0.48.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 (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/__tests__/views/collection-list-facets.render.test.d.ts +2 -0
  3. package/dist/__tests__/views/collection-list-facets.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/views/collection-list-facets.render.test.js +131 -0
  5. package/dist/__tests__/views/collection-list-facets.render.test.js.map +1 -0
  6. package/dist/__tests__/views/use-site-preview.render.test.d.ts +2 -0
  7. package/dist/__tests__/views/use-site-preview.render.test.d.ts.map +1 -0
  8. package/dist/__tests__/views/use-site-preview.render.test.js +86 -0
  9. package/dist/__tests__/views/use-site-preview.render.test.js.map +1 -0
  10. package/dist/views/CollectionList.d.ts.map +1 -1
  11. package/dist/views/CollectionList.js +121 -11
  12. package/dist/views/CollectionList.js.map +1 -1
  13. package/dist/views/page-editor/EditorTopBar.d.ts +8 -1
  14. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  15. package/dist/views/page-editor/EditorTopBar.js +7 -3
  16. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  17. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  18. package/dist/views/page-editor/PageSectionEditor.js +14 -1
  19. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  20. package/dist/views/page-editor/SitePreviewFrame.d.ts +18 -0
  21. package/dist/views/page-editor/SitePreviewFrame.d.ts.map +1 -0
  22. package/dist/views/page-editor/SitePreviewFrame.js +45 -0
  23. package/dist/views/page-editor/SitePreviewFrame.js.map +1 -0
  24. package/dist/views/page-editor/useSitePreview.d.ts +33 -0
  25. package/dist/views/page-editor/useSitePreview.d.ts.map +1 -0
  26. package/dist/views/page-editor/useSitePreview.js +72 -0
  27. package/dist/views/page-editor/useSitePreview.js.map +1 -0
  28. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  29. package/dist/views/post-editor/PostSectionEditor.js +14 -1
  30. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/views/collection-list-facets.render.test.tsx +148 -0
  33. package/src/__tests__/views/use-site-preview.render.test.tsx +122 -0
  34. package/src/views/CollectionList.tsx +187 -23
  35. package/src/views/page-editor/EditorTopBar.tsx +31 -0
  36. package/src/views/page-editor/PageSectionEditor.tsx +33 -8
  37. package/src/views/page-editor/SitePreviewFrame.tsx +85 -0
  38. package/src/views/page-editor/useSitePreview.ts +117 -0
  39. package/src/views/post-editor/PostSectionEditor.tsx +35 -10
@@ -0,0 +1,148 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
4
+
5
+ /**
6
+ * CollectionList facet filters + taxonomy columns (taxonomy Phase B).
7
+ * Pins: facet dropdowns appear for select fields (config options), free-text
8
+ * `category`, and tag-shaped arrays (in-use values via field-values API);
9
+ * picking one adds `filter.<field>=` to the list endpoint; and taxonomy
10
+ * columns surface category/tags values in the table.
11
+ */
12
+
13
+ let fieldValues: Record<string, Array<{ value: string; count: number }>> = {}
14
+
15
+ const cmsApi = vi.fn(async (endpoint: string, _options?: unknown) => {
16
+ const match = endpoint.match(/field-values\?field=([^&]+)/)
17
+ if (match) {
18
+ const field = decodeURIComponent(match[1] ?? '')
19
+ return { data: { field, values: fieldValues[field] ?? [] }, status: 200 }
20
+ }
21
+ return { data: {}, status: 200 }
22
+ })
23
+
24
+ vi.mock('../../lib/api.js', () => ({
25
+ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as never),
26
+ ensureCsrfToken: vi.fn(async () => {}),
27
+ }))
28
+
29
+ let lastEndpoint = ''
30
+ let listDocs: any[] = []
31
+
32
+ vi.mock('../../lib/useApiData.js', () => ({
33
+ useApiData: (endpoint: string) => {
34
+ lastEndpoint = endpoint
35
+ return {
36
+ data: { docs: listDocs, total: listDocs.length },
37
+ loading: false,
38
+ error: null,
39
+ refetch: vi.fn(),
40
+ }
41
+ },
42
+ }))
43
+
44
+ const { CollectionList } = await import('../../views/CollectionList.js')
45
+
46
+ const CONFIG = {
47
+ collections: {
48
+ posts: {
49
+ slug: 'posts',
50
+ labels: { singular: 'Post', plural: 'Posts' },
51
+ fields: {
52
+ title: { type: 'text', label: 'Title' },
53
+ topic: {
54
+ type: 'select',
55
+ label: 'Topic',
56
+ options: [
57
+ { label: 'News', value: 'news' },
58
+ { label: 'Tutorials', value: 'tutorials' },
59
+ ],
60
+ },
61
+ category: { type: 'text', label: 'Category', maxLength: 80 },
62
+ body: { type: 'richText', label: 'Body' },
63
+ tags: {
64
+ type: 'array',
65
+ label: 'Tags',
66
+ fields: { tag: { type: 'text', label: 'Tag', required: true } },
67
+ },
68
+ },
69
+ },
70
+ },
71
+ }
72
+
73
+ function renderList() {
74
+ return render(<CollectionList collectionSlug="posts" config={CONFIG} onNavigate={vi.fn()} />)
75
+ }
76
+
77
+ beforeEach(() => {
78
+ cmsApi.mockClear()
79
+ fieldValues = {}
80
+ listDocs = []
81
+ lastEndpoint = ''
82
+ })
83
+
84
+ describe('CollectionList facets', () => {
85
+ it('renders facet dropdowns for select, category, and tag-shaped fields', async () => {
86
+ fieldValues = {
87
+ category: [{ value: 'Releases', count: 4 }],
88
+ tags: [{ value: 'AI', count: 3 }],
89
+ }
90
+ renderList()
91
+
92
+ expect(screen.getByLabelText('Filter by Topic')).toBeTruthy()
93
+ await waitFor(() => {
94
+ expect(screen.getByLabelText('Filter by Category')).toBeTruthy()
95
+ expect(screen.getByLabelText('Filter by Tags')).toBeTruthy()
96
+ })
97
+ // Select facets list config option labels, not in-use values.
98
+ expect(screen.getByRole('option', { name: 'Tutorials' })).toBeTruthy()
99
+ expect(screen.getByRole('option', { name: 'Releases' })).toBeTruthy()
100
+ })
101
+
102
+ it('adds filter.<field> to the list endpoint when a facet is picked', async () => {
103
+ fieldValues = { tags: [{ value: 'AI', count: 3 }] }
104
+ renderList()
105
+
106
+ fireEvent.change(screen.getByLabelText('Filter by Topic'), { target: { value: 'news' } })
107
+ await waitFor(() => expect(lastEndpoint).toContain('filter.topic=news'))
108
+
109
+ await waitFor(() => expect(screen.getByLabelText('Filter by Tags')).toBeTruthy())
110
+ fireEvent.change(screen.getByLabelText('Filter by Tags'), { target: { value: 'AI' } })
111
+ await waitFor(() => {
112
+ expect(lastEndpoint).toContain('filter.topic=news')
113
+ expect(lastEndpoint).toContain('filter.tags=AI')
114
+ })
115
+ })
116
+
117
+ it('clears all facets via the clear button', async () => {
118
+ renderList()
119
+ fireEvent.change(screen.getByLabelText('Filter by Topic'), { target: { value: 'news' } })
120
+ await waitFor(() => expect(lastEndpoint).toContain('filter.topic=news'))
121
+
122
+ fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
123
+ await waitFor(() => expect(lastEndpoint).not.toContain('filter.'))
124
+ })
125
+
126
+ it('shows taxonomy columns with select labels and tag lists', async () => {
127
+ listDocs = [
128
+ {
129
+ id: '1',
130
+ title: 'Hello',
131
+ status: 'PUBLISHED',
132
+ updatedAt: '2026-06-01T00:00:00Z',
133
+ data: { topic: 'news', category: 'Releases', tags: [{ tag: 'AI' }, 'CMS'] },
134
+ },
135
+ ]
136
+ renderList()
137
+
138
+ // Column headers for each facet field.
139
+ expect(screen.getByRole('columnheader', { name: 'Topic' })).toBeTruthy()
140
+ expect(screen.getByRole('columnheader', { name: 'Category' })).toBeTruthy()
141
+ expect(screen.getByRole('columnheader', { name: 'Tags' })).toBeTruthy()
142
+ // Select value maps to its config label; tags render row + string shapes.
143
+ const table = within(screen.getByRole('table'))
144
+ expect(table.getByText('News')).toBeTruthy()
145
+ expect(table.getByText('Releases')).toBeTruthy()
146
+ expect(table.getByText('AI, CMS')).toBeTruthy()
147
+ })
148
+ })
@@ -0,0 +1,122 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ /**
6
+ * useSitePreview — drives the inline branded preview (iframe of the public
7
+ * site rendering a draft). Pins: token minted + URL built on activation,
8
+ * unsaved drafts are persisted first (a failed save cancels activation), and
9
+ * the frame reloads (reloadKey bump) after each completed save.
10
+ */
11
+
12
+ const createPreviewToken = vi.fn(async () => ({
13
+ token: 'tok-123',
14
+ expiresAt: '2026-06-19T00:00:00Z',
15
+ }))
16
+
17
+ vi.mock('../../lib/preview-link.js', () => ({
18
+ createPreviewToken: (...args: unknown[]) => createPreviewToken(...(args as [])),
19
+ buildPublicPreviewUrl: ({ siteUrl, urlPrefix, slug, token }: any) =>
20
+ `${siteUrl}${urlPrefix ? `/${urlPrefix}` : ''}/${slug}?preview=${token}`,
21
+ resolveSiteUrl: (u?: string | null) => u ?? 'https://fallback.test',
22
+ }))
23
+
24
+ vi.mock('sonner', () => ({
25
+ toast: { error: vi.fn(), success: vi.fn() },
26
+ }))
27
+
28
+ const { useSitePreview } = await import('../../views/page-editor/useSitePreview.js')
29
+ const { toast } = await import('sonner')
30
+
31
+ import type { SaveState } from '../../views/page-editor/EditorTopBar.js'
32
+
33
+ function Harness({
34
+ documentId = 'doc-1',
35
+ slug = 'about',
36
+ dirty = false,
37
+ saveState = 'idle' as SaveState,
38
+ saveDraft = vi.fn(async () => true),
39
+ }: {
40
+ documentId?: string | null
41
+ slug?: string | null
42
+ dirty?: boolean
43
+ saveState?: SaveState
44
+ saveDraft?: () => Promise<boolean>
45
+ }) {
46
+ const sp = useSitePreview({
47
+ collection: 'pages',
48
+ documentId,
49
+ slug,
50
+ urlPrefix: '',
51
+ siteUrl: 'https://site.test',
52
+ dirty,
53
+ saveState,
54
+ saveDraft,
55
+ })
56
+ return (
57
+ <div>
58
+ <button type="button" onClick={sp.toggle}>
59
+ toggle
60
+ </button>
61
+ <output data-testid="state">
62
+ {JSON.stringify({ active: sp.active, url: sp.url, reloadKey: sp.reloadKey })}
63
+ </output>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ function state(): { active: boolean; url: string | null; reloadKey: number } {
69
+ return JSON.parse(screen.getByTestId('state').textContent ?? '{}')
70
+ }
71
+
72
+ beforeEach(() => {
73
+ createPreviewToken.mockClear()
74
+ vi.mocked(toast.error).mockClear()
75
+ })
76
+
77
+ describe('useSitePreview', () => {
78
+ it('mints a token and builds the public draft URL on activation', async () => {
79
+ render(<Harness />)
80
+ fireEvent.click(screen.getByText('toggle'))
81
+ expect(state().active).toBe(true)
82
+ await waitFor(() => {
83
+ expect(state().url).toBe('https://site.test/about?preview=tok-123')
84
+ })
85
+ expect(createPreviewToken).toHaveBeenCalledWith('pages', 'doc-1')
86
+ })
87
+
88
+ it('saves a dirty draft before minting; a failed save cancels activation', async () => {
89
+ const saveDraft = vi.fn(async () => false)
90
+ render(<Harness dirty saveDraft={saveDraft} />)
91
+ fireEvent.click(screen.getByText('toggle'))
92
+ await waitFor(() => expect(saveDraft).toHaveBeenCalled())
93
+ await waitFor(() => expect(state().active).toBe(false))
94
+ expect(createPreviewToken).not.toHaveBeenCalled()
95
+ })
96
+
97
+ it('refuses to activate for unsaved documents', () => {
98
+ render(<Harness documentId={null} />)
99
+ fireEvent.click(screen.getByText('toggle'))
100
+ expect(state().active).toBe(false)
101
+ expect(toast.error).toHaveBeenCalled()
102
+ })
103
+
104
+ it('toggles back to the canvas on second click', async () => {
105
+ render(<Harness />)
106
+ fireEvent.click(screen.getByText('toggle'))
107
+ await waitFor(() => expect(state().url).not.toBeNull())
108
+ fireEvent.click(screen.getByText('toggle'))
109
+ expect(state().active).toBe(false)
110
+ })
111
+
112
+ it('bumps reloadKey after a save completes while the preview is open', async () => {
113
+ const { rerender } = render(<Harness saveState="idle" />)
114
+ fireEvent.click(screen.getByText('toggle'))
115
+ await waitFor(() => expect(state().url).not.toBeNull())
116
+ expect(state().reloadKey).toBe(0)
117
+
118
+ rerender(<Harness saveState="saving" />)
119
+ rerender(<Harness saveState="saved" />)
120
+ await waitFor(() => expect(state().reloadKey).toBe(1))
121
+ })
122
+ })
@@ -24,14 +24,79 @@ export interface CollectionListProps {
24
24
  type SortField = 'title' | 'status' | 'updatedAt'
25
25
  type SortOrder = 'asc' | 'desc'
26
26
 
27
- function resolveLabel(config: any, slug: string): { singular: string; plural: string } {
28
- const fallback = { singular: slug, plural: slug }
29
- if (!config?.collections) return fallback
27
+ function resolveCollection(config: any, slug: string): any | undefined {
28
+ if (!config?.collections) return undefined
30
29
  const list = Array.isArray(config.collections)
31
30
  ? config.collections
32
31
  : Object.values(config.collections)
33
- const match = (list as any[]).find((c: any) => c.slug === slug)
34
- return match?.labels ?? fallback
32
+ return (list as any[]).find((c: any) => c.slug === slug)
33
+ }
34
+
35
+ function resolveLabel(config: any, slug: string): { singular: string; plural: string } {
36
+ return resolveCollection(config, slug)?.labels ?? { singular: slug, plural: slug }
37
+ }
38
+
39
+ /** One facet dropdown in the filter bar. */
40
+ interface FacetDef {
41
+ name: string
42
+ label: string
43
+ /** `options` = fixed list from the config; `values` = in-use values from the field-values API. */
44
+ source: 'options' | 'values'
45
+ options?: Array<{ label: string; value: string }>
46
+ }
47
+
48
+ /** True for array fields shaped like tags: a single required text sub-field. */
49
+ function isTagShapedArray(field: any): boolean {
50
+ if (field?.type !== 'array' || !field.fields) return false
51
+ const subs = Object.values(field.fields) as any[]
52
+ return subs.length === 1 && subs[0]?.type === 'text'
53
+ }
54
+
55
+ /**
56
+ * Facetable fields for the filter bar (taxonomy Phase B): structured selects
57
+ * use their config options; free-text `category` and tag-shaped arrays load
58
+ * the collection's in-use values. Capped so the bar stays scannable.
59
+ */
60
+ function facetFieldsFor(config: any, slug: string): FacetDef[] {
61
+ const fields = resolveCollection(config, slug)?.fields
62
+ if (!fields || typeof fields !== 'object') return []
63
+ const out: FacetDef[] = []
64
+ for (const [name, field] of Object.entries(fields) as Array<[string, any]>) {
65
+ if (field?.hidden) continue
66
+ if (field?.type === 'select' && Array.isArray(field.options)) {
67
+ out.push({ name, label: field.label ?? name, source: 'options', options: field.options })
68
+ } else if (field?.type === 'text' && name === 'category') {
69
+ out.push({ name, label: field.label ?? 'Category', source: 'values' })
70
+ } else if (isTagShapedArray(field)) {
71
+ out.push({ name, label: field.label ?? name, source: 'values' })
72
+ }
73
+ if (out.length >= 3) break
74
+ }
75
+ return out
76
+ }
77
+
78
+ /** Read a doc's display value for a facet column (select values map to labels). */
79
+ function facetCellValue(doc: any, facet: FacetDef): string {
80
+ const raw = doc?.data?.[facet.name]
81
+ if (typeof raw === 'string') {
82
+ if (!raw) return ''
83
+ const opt = facet.options?.find((o) => o.value === raw)
84
+ return opt?.label ?? raw
85
+ }
86
+ if (Array.isArray(raw)) {
87
+ const tags = raw
88
+ .map((item) =>
89
+ typeof item === 'string'
90
+ ? item
91
+ : item && typeof item === 'object'
92
+ ? Object.values(item).find((v) => typeof v === 'string')
93
+ : undefined,
94
+ )
95
+ .filter((v): v is string => Boolean(v))
96
+ if (tags.length === 0) return ''
97
+ return tags.length > 3 ? `${tags.slice(0, 3).join(', ')} +${tags.length - 3}` : tags.join(', ')
98
+ }
99
+ return ''
35
100
  }
36
101
 
37
102
  function statusColor(status: string): string {
@@ -75,6 +140,7 @@ function formatDate(d: string | undefined): string {
75
140
 
76
141
  export function CollectionList({ collectionSlug, config, onNavigate }: CollectionListProps) {
77
142
  const labels = useMemo(() => resolveLabel(config, collectionSlug), [config, collectionSlug])
143
+ const facets = useMemo(() => facetFieldsFor(config, collectionSlug), [config, collectionSlug])
78
144
 
79
145
  const [page, setPage] = useState(1)
80
146
  const [search, setSearch] = useState('')
@@ -82,6 +148,9 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
82
148
  const [sort, setSort] = useState<SortField | null>(null)
83
149
  const [order, setOrder] = useState<SortOrder>('asc')
84
150
  const [selected, setSelected] = useState<Set<string>>(new Set())
151
+ const [filters, setFilters] = useState<Record<string, string>>({})
152
+ // In-use values per `values`-sourced facet, fetched once per collection.
153
+ const [facetValues, setFacetValues] = useState<Record<string, string[]>>({})
85
154
  const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
86
155
 
87
156
  useEffect(() => {
@@ -97,8 +166,11 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
97
166
  let url = `/collections/${collectionSlug}?page=${page}&pageSize=25`
98
167
  if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`
99
168
  if (sort) url += `&sort=${sort}&order=${order}`
169
+ for (const [field, value] of Object.entries(filters)) {
170
+ if (value) url += `&filter.${encodeURIComponent(field)}=${encodeURIComponent(value)}`
171
+ }
100
172
  return url
101
- }, [collectionSlug, page, debouncedSearch, sort, order])
173
+ }, [collectionSlug, page, debouncedSearch, sort, order, filters])
102
174
 
103
175
  const { data, loading, error, refetch } = useApiData<{ docs: any[]; total: number }>(endpoint)
104
176
  const docs = data?.docs ?? []
@@ -109,8 +181,40 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
109
181
  setPage(1)
110
182
  setSelected(new Set())
111
183
  setSearch('')
184
+ setFilters({})
185
+ setFacetValues({})
112
186
  }, [collectionSlug])
113
187
 
188
+ // Load in-use values for facets that aren't config-defined selects.
189
+ useEffect(() => {
190
+ let cancelled = false
191
+ for (const facet of facets) {
192
+ if (facet.source !== 'values') continue
193
+ cmsApi<{ field: string; values: Array<{ value: string; count: number }> }>(
194
+ `/collections/${collectionSlug}/field-values?field=${encodeURIComponent(facet.name)}`,
195
+ ).then((res) => {
196
+ if (cancelled || !res.data?.values) return
197
+ setFacetValues((prev) => ({
198
+ ...prev,
199
+ [facet.name]: res.data!.values.map((v) => v.value),
200
+ }))
201
+ })
202
+ }
203
+ return () => {
204
+ cancelled = true
205
+ }
206
+ }, [collectionSlug, facets])
207
+
208
+ const setFilter = useCallback((field: string, value: string) => {
209
+ setFilters((prev) => {
210
+ const next = { ...prev }
211
+ if (value) next[field] = value
212
+ else delete next[field]
213
+ return next
214
+ })
215
+ setPage(1)
216
+ }, [])
217
+
114
218
  const toggleSort = useCallback((field: SortField) => {
115
219
  setSort((prev) => {
116
220
  if (prev === field) {
@@ -198,23 +302,64 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
198
302
  </button>
199
303
  </div>
200
304
 
201
- {/* Search */}
202
- <div className="relative mb-4">
203
- <Search
204
- className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
205
- style={{ color: 'var(--actuate-text-muted, #9ca3af)' }}
206
- />
207
- <input
208
- type="text"
209
- placeholder={`Search ${labels.plural.toLowerCase()}...`}
210
- value={search}
211
- onChange={(e) => setSearch(e.target.value)}
212
- className="w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus:ring-2 focus:outline-none"
213
- style={{
214
- borderColor: 'var(--actuate-border, #d1d5db)',
215
- color: 'var(--actuate-text, #111827)',
216
- }}
217
- />
305
+ {/* Search + facet filters */}
306
+ <div className="mb-4 flex flex-wrap items-center gap-2">
307
+ <div className="relative min-w-[200px] flex-1">
308
+ <Search
309
+ className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
310
+ style={{ color: 'var(--actuate-text-muted, #9ca3af)' }}
311
+ />
312
+ <input
313
+ type="text"
314
+ placeholder={`Search ${labels.plural.toLowerCase()}...`}
315
+ value={search}
316
+ onChange={(e) => setSearch(e.target.value)}
317
+ className="w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus:ring-2 focus:outline-none"
318
+ style={{
319
+ borderColor: 'var(--actuate-border, #d1d5db)',
320
+ color: 'var(--actuate-text, #111827)',
321
+ }}
322
+ />
323
+ </div>
324
+ {facets.map((facet) => {
325
+ const values =
326
+ facet.source === 'options'
327
+ ? (facet.options ?? [])
328
+ : (facetValues[facet.name] ?? []).map((v) => ({ label: v, value: v }))
329
+ if (values.length === 0) return null
330
+ return (
331
+ <select
332
+ key={facet.name}
333
+ aria-label={`Filter by ${facet.label}`}
334
+ value={filters[facet.name] ?? ''}
335
+ onChange={(e) => setFilter(facet.name, e.target.value)}
336
+ className="rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
337
+ style={{
338
+ borderColor: 'var(--actuate-border, #d1d5db)',
339
+ color: filters[facet.name]
340
+ ? 'var(--actuate-text, #111827)'
341
+ : 'var(--actuate-text-secondary, #6b7280)',
342
+ }}
343
+ >
344
+ <option value="">All {facet.label.toLowerCase()}</option>
345
+ {values.map((opt) => (
346
+ <option key={opt.value} value={opt.value}>
347
+ {opt.label}
348
+ </option>
349
+ ))}
350
+ </select>
351
+ )
352
+ })}
353
+ {Object.keys(filters).length > 0 && (
354
+ <button
355
+ type="button"
356
+ onClick={() => setFilters({})}
357
+ className="rounded-lg px-2 py-2 text-sm underline transition-opacity hover:opacity-75"
358
+ style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}
359
+ >
360
+ Clear filters
361
+ </button>
362
+ )}
218
363
  </div>
219
364
 
220
365
  {/* Bulk actions */}
@@ -317,6 +462,15 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
317
462
  <th className="px-3 py-2 text-left">
318
463
  <SortCol field="title">Title</SortCol>
319
464
  </th>
465
+ {facets.map((facet) => (
466
+ <th
467
+ key={facet.name}
468
+ className="px-3 py-2 text-left text-xs font-medium"
469
+ style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}
470
+ >
471
+ {facet.label}
472
+ </th>
473
+ ))}
320
474
  <th className="px-3 py-2 text-left">
321
475
  <SortCol field="status">Status</SortCol>
322
476
  </th>
@@ -356,6 +510,16 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
356
510
  {doc.title || doc.name || `#${doc.id}`}
357
511
  </button>
358
512
  </td>
513
+ {facets.map((facet) => (
514
+ <td
515
+ key={facet.name}
516
+ className="max-w-[200px] truncate px-3 py-2"
517
+ style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}
518
+ title={facetCellValue(doc, facet) || undefined}
519
+ >
520
+ {facetCellValue(doc, facet) || '—'}
521
+ </td>
522
+ ))}
359
523
  <td className="px-3 py-2">
360
524
  <span
361
525
  className="inline-block rounded-full px-2 py-0.5 text-xs font-medium"
@@ -4,6 +4,7 @@ import {
4
4
  Check,
5
5
  ExternalLink,
6
6
  Eye,
7
+ Globe,
7
8
  History,
8
9
  Laptop,
9
10
  Loader2,
@@ -44,6 +45,13 @@ interface EditorTopBarProps {
44
45
  * hidden — e.g. for unsaved documents that have no versions yet.
45
46
  */
46
47
  onHistory?: () => void
48
+ /** Whether the inline site preview (iframe of the public site) is showing. */
49
+ sitePreview?: boolean
50
+ /**
51
+ * Toggle the inline site preview — renders the draft through the consumer's
52
+ * real public route inside the editor. Hidden when omitted (unsaved docs).
53
+ */
54
+ onToggleSitePreview?: () => void
47
55
  onOpenSettings: () => void
48
56
  onSaveDraft: () => void
49
57
  onPublish: () => void
@@ -85,6 +93,8 @@ export function EditorTopBar({
85
93
  onPreview,
86
94
  onViewOnSite,
87
95
  onHistory,
96
+ sitePreview = false,
97
+ onToggleSitePreview,
88
98
  onOpenSettings,
89
99
  onSaveDraft,
90
100
  onPublish,
@@ -187,6 +197,27 @@ export function EditorTopBar({
187
197
  <Eye className="h-4 w-4" aria-hidden />
188
198
  </button>
189
199
 
200
+ {onToggleSitePreview && (
201
+ <button
202
+ type="button"
203
+ onClick={onToggleSitePreview}
204
+ aria-pressed={sitePreview}
205
+ aria-label={sitePreview ? 'Back to editor canvas' : 'Inline site preview'}
206
+ title={
207
+ sitePreview
208
+ ? 'Back to editor canvas'
209
+ : 'Inline site preview — render the draft through your public site, in place'
210
+ }
211
+ className={`rounded-lg p-2 transition-colors ${
212
+ sitePreview
213
+ ? 'bg-primary text-primary-foreground'
214
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
215
+ }`}
216
+ >
217
+ <Globe className="h-4 w-4" aria-hidden />
218
+ </button>
219
+ )}
220
+
190
221
  {onViewOnSite &&
191
222
  (structural ? (
192
223
  <button
@@ -37,6 +37,8 @@ import { hasCanvasRenderer, useAdminSectionRenderers } from './sections/index.js
37
37
  import { EditorTopBar, type SaveState } from './EditorTopBar.js'
38
38
  import { SectionsPanel } from './SectionsPanel.js'
39
39
  import { EditorCanvas } from './EditorCanvas.js'
40
+ import { SitePreviewFrame } from './SitePreviewFrame.js'
41
+ import { useSitePreview } from './useSitePreview.js'
40
42
  import { SectionInspector } from './SectionInspector.js'
41
43
  import { AddSectionModal } from './AddSectionModal.js'
42
44
  import { PageSettingsModal } from './PageSettingsModal.js'
@@ -263,6 +265,18 @@ export function PageSectionEditor({
263
265
  }
264
266
  }, [page, documentId, onNavigate])
265
267
 
268
+ // Inline branded preview — iframe of the public site rendering this draft.
269
+ const sitePreview = useSitePreview({
270
+ collection: 'pages',
271
+ documentId: page?.id,
272
+ slug: page?.slug,
273
+ urlPrefix: '',
274
+ siteUrl: config?.seo?.siteUrl,
275
+ dirty,
276
+ saveState,
277
+ saveDraft: handleSaveDraft,
278
+ })
279
+
266
280
  const requestPublish = useCallback(() => {
267
281
  if (!page) return
268
282
  const result = validatePage(page, true)
@@ -450,6 +464,8 @@ export function PageSectionEditor({
450
464
  onBack={() => guardedNavigate('/pages')}
451
465
  onPreview={handlePreview}
452
466
  onViewOnSite={page.id ? handleViewOnSite : undefined}
467
+ sitePreview={sitePreview.active}
468
+ onToggleSitePreview={page.id && page.slug ? sitePreview.toggle : undefined}
453
469
  onHistory={page.id ? () => setHistoryOpen(true) : undefined}
454
470
  onOpenSettings={() => setSettingsOpen(true)}
455
471
  onSaveDraft={handleSaveDraft}
@@ -519,14 +535,23 @@ export function PageSectionEditor({
519
535
  />
520
536
 
521
537
  <div className="flex-1 overflow-hidden">
522
- <EditorCanvas
523
- sections={page.sections}
524
- selectedId={selectedId}
525
- viewport={viewport}
526
- onSelect={setSelectedId}
527
- onAddSection={() => setAddOpen(true)}
528
- structuralTypes={structuralTypes}
529
- />
538
+ {sitePreview.active ? (
539
+ <SitePreviewFrame
540
+ src={sitePreview.url}
541
+ viewport={viewport}
542
+ reloadKey={sitePreview.reloadKey}
543
+ title={`Site preview: ${page.title || 'Untitled page'}`}
544
+ />
545
+ ) : (
546
+ <EditorCanvas
547
+ sections={page.sections}
548
+ selectedId={selectedId}
549
+ viewport={viewport}
550
+ onSelect={setSelectedId}
551
+ onAddSection={() => setAddOpen(true)}
552
+ structuralTypes={structuralTypes}
553
+ />
554
+ )}
530
555
  </div>
531
556
 
532
557
  {selected && (