@actuate-media/cms-admin 0.45.0 → 0.47.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 (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/__tests__/components/tag-input.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/tag-input.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/tag-input.render.test.js +86 -0
  5. package/dist/__tests__/components/tag-input.render.test.js.map +1 -0
  6. package/dist/__tests__/lib/post-editor-service.test.js +26 -0
  7. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  8. package/dist/__tests__/views/collection-list-facets.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/collection-list-facets.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/collection-list-facets.render.test.js +131 -0
  11. package/dist/__tests__/views/collection-list-facets.render.test.js.map +1 -0
  12. package/dist/__tests__/views/post-fields-modal.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/post-fields-modal.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/post-fields-modal.render.test.js +86 -0
  15. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -0
  16. package/dist/actuate-admin.css +1 -1
  17. package/dist/components/TagInput.d.ts +16 -0
  18. package/dist/components/TagInput.d.ts.map +1 -0
  19. package/dist/components/TagInput.js +105 -0
  20. package/dist/components/TagInput.js.map +1 -0
  21. package/dist/lib/post-editor-service.d.ts +8 -0
  22. package/dist/lib/post-editor-service.d.ts.map +1 -1
  23. package/dist/lib/post-editor-service.js +25 -0
  24. package/dist/lib/post-editor-service.js.map +1 -1
  25. package/dist/views/CollectionList.d.ts.map +1 -1
  26. package/dist/views/CollectionList.js +121 -11
  27. package/dist/views/CollectionList.js.map +1 -1
  28. package/dist/views/post-editor/PostFieldsModal.d.ts +17 -2
  29. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  30. package/dist/views/post-editor/PostFieldsModal.js +13 -4
  31. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  32. package/dist/views/post-editor/PostSectionEditor.d.ts +2 -0
  33. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  34. package/dist/views/post-editor/PostSectionEditor.js +1 -1
  35. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/__tests__/components/tag-input.render.test.tsx +103 -0
  38. package/src/__tests__/lib/post-editor-service.test.ts +28 -0
  39. package/src/__tests__/views/collection-list-facets.render.test.tsx +148 -0
  40. package/src/__tests__/views/post-fields-modal.render.test.tsx +126 -0
  41. package/src/components/TagInput.tsx +196 -0
  42. package/src/lib/post-editor-service.ts +28 -0
  43. package/src/views/CollectionList.tsx +187 -23
  44. package/src/views/post-editor/PostFieldsModal.tsx +79 -10
  45. package/src/views/post-editor/PostSectionEditor.tsx +11 -2
@@ -0,0 +1,196 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Chip-style tag editor with autocomplete from values already in use.
5
+ *
6
+ * Suggestions come from `GET /collections/:collection/field-values?field=...`
7
+ * (distinct values across the collection, most-used first) and are fetched
8
+ * once on first focus, then filtered client-side as the user types.
9
+ *
10
+ * Deduplication is case-insensitive and resolves to the existing canonical
11
+ * casing: typing "ai" when the collection already uses "AI" adds "AI", so a
12
+ * vocabulary never splinters into `AI` / `ai` / `Ai` variants.
13
+ */
14
+ import { useCallback, useEffect, useRef, useState } from 'react'
15
+ import { X } from 'lucide-react'
16
+ import { cmsApi } from '../lib/api.js'
17
+
18
+ interface TagInputProps {
19
+ id: string
20
+ value: string[]
21
+ onChange: (tags: string[]) => void
22
+ /** Collection slug used to fetch suggestions. Omit to disable autocomplete. */
23
+ collection?: string
24
+ /** Field to aggregate for suggestions (default `tags`). */
25
+ field?: string
26
+ disabled?: boolean
27
+ placeholder?: string
28
+ /** Hard cap on the number of tags (mirrors the field's `maxRows`). */
29
+ maxTags?: number
30
+ }
31
+
32
+ interface FieldValuesResponse {
33
+ field: string
34
+ values: Array<{ value: string; count: number }>
35
+ }
36
+
37
+ const INPUT =
38
+ 'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
39
+
40
+ export function TagInput({
41
+ id,
42
+ value,
43
+ onChange,
44
+ collection,
45
+ field = 'tags',
46
+ disabled,
47
+ placeholder,
48
+ maxTags,
49
+ }: TagInputProps) {
50
+ const [draft, setDraft] = useState('')
51
+ const [known, setKnown] = useState<string[] | null>(null)
52
+ const [open, setOpen] = useState(false)
53
+ const [activeIndex, setActiveIndex] = useState(-1)
54
+ const rootRef = useRef<HTMLDivElement>(null)
55
+
56
+ const atCap = typeof maxTags === 'number' && value.length >= maxTags
57
+
58
+ // Fetch the in-use vocabulary once, on first focus.
59
+ const ensureSuggestions = useCallback(() => {
60
+ if (known !== null || !collection) return
61
+ setKnown([]) // sentinel: fetch in flight, don't refetch
62
+ cmsApi<FieldValuesResponse>(
63
+ `/collections/${encodeURIComponent(collection)}/field-values?field=${encodeURIComponent(field)}`,
64
+ ).then((res) => {
65
+ if (res.data?.values) setKnown(res.data.values.map((v) => v.value))
66
+ })
67
+ }, [known, collection, field])
68
+
69
+ // Close the suggestion list on outside click.
70
+ useEffect(() => {
71
+ if (!open) return
72
+ function onPointerDown(e: PointerEvent) {
73
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
74
+ }
75
+ document.addEventListener('pointerdown', onPointerDown)
76
+ return () => document.removeEventListener('pointerdown', onPointerDown)
77
+ }, [open])
78
+
79
+ const lowerExisting = new Set(value.map((t) => t.toLowerCase()))
80
+ const matches = (known ?? [])
81
+ .filter((s) => !lowerExisting.has(s.toLowerCase()))
82
+ .filter((s) => !draft.trim() || s.toLowerCase().includes(draft.trim().toLowerCase()))
83
+ .slice(0, 8)
84
+
85
+ function addTag(raw: string) {
86
+ const tag = raw.trim()
87
+ if (!tag || atCap) return
88
+ const lower = tag.toLowerCase()
89
+ if (lowerExisting.has(lower)) {
90
+ setDraft('')
91
+ return
92
+ }
93
+ // Prefer the canonical casing already used elsewhere in the collection.
94
+ const canonical = (known ?? []).find((s) => s.toLowerCase() === lower) ?? tag
95
+ onChange([...value, canonical])
96
+ setDraft('')
97
+ setActiveIndex(-1)
98
+ }
99
+
100
+ function removeTag(index: number) {
101
+ onChange(value.filter((_, i) => i !== index))
102
+ }
103
+
104
+ function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
105
+ if (e.key === 'Enter' || e.key === ',') {
106
+ e.preventDefault()
107
+ if (activeIndex >= 0 && activeIndex < matches.length) addTag(matches[activeIndex]!)
108
+ else addTag(draft)
109
+ } else if (e.key === 'ArrowDown') {
110
+ e.preventDefault()
111
+ setOpen(true)
112
+ setActiveIndex((i) => Math.min(i + 1, matches.length - 1))
113
+ } else if (e.key === 'ArrowUp') {
114
+ e.preventDefault()
115
+ setActiveIndex((i) => Math.max(i - 1, -1))
116
+ } else if (e.key === 'Escape') {
117
+ setOpen(false)
118
+ setActiveIndex(-1)
119
+ } else if (e.key === 'Backspace' && !draft && value.length > 0) {
120
+ removeTag(value.length - 1)
121
+ }
122
+ }
123
+
124
+ return (
125
+ <div ref={rootRef} className="relative">
126
+ {value.length > 0 && (
127
+ <div className="mb-1.5 flex flex-wrap gap-1.5">
128
+ {value.map((tag, i) => (
129
+ <span
130
+ key={`${tag}-${i}`}
131
+ className="bg-accent text-foreground inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs"
132
+ >
133
+ {tag}
134
+ {!disabled && (
135
+ <button
136
+ type="button"
137
+ aria-label={`Remove tag ${tag}`}
138
+ onClick={() => removeTag(i)}
139
+ className="text-muted-foreground hover:text-destructive rounded p-0.5"
140
+ >
141
+ <X className="h-3 w-3" aria-hidden />
142
+ </button>
143
+ )}
144
+ </span>
145
+ ))}
146
+ </div>
147
+ )}
148
+ <input
149
+ id={id}
150
+ className={INPUT}
151
+ role="combobox"
152
+ aria-expanded={open && matches.length > 0}
153
+ aria-autocomplete="list"
154
+ aria-controls={`${id}-listbox`}
155
+ value={draft}
156
+ disabled={disabled || atCap}
157
+ placeholder={atCap ? `Maximum of ${maxTags} tags` : (placeholder ?? 'Add a tag…')}
158
+ onFocus={() => {
159
+ ensureSuggestions()
160
+ setOpen(true)
161
+ }}
162
+ onChange={(e) => {
163
+ setDraft(e.target.value)
164
+ setOpen(true)
165
+ setActiveIndex(-1)
166
+ }}
167
+ onKeyDown={handleKeyDown}
168
+ />
169
+ {open && matches.length > 0 && !disabled && (
170
+ <ul
171
+ id={`${id}-listbox`}
172
+ role="listbox"
173
+ aria-label="Tag suggestions"
174
+ className="border-border bg-card absolute z-20 mt-1 max-h-44 w-full overflow-y-auto rounded-md border py-1 shadow-md"
175
+ >
176
+ {matches.map((s, i) => (
177
+ <li key={s} role="option" aria-selected={i === activeIndex}>
178
+ <button
179
+ type="button"
180
+ className={`w-full px-2.5 py-1.5 text-left text-sm ${
181
+ i === activeIndex
182
+ ? 'bg-accent text-foreground'
183
+ : 'text-foreground hover:bg-accent'
184
+ }`}
185
+ onMouseEnter={() => setActiveIndex(i)}
186
+ onClick={() => addTag(s)}
187
+ >
188
+ {s}
189
+ </button>
190
+ </li>
191
+ ))}
192
+ </ul>
193
+ )}
194
+ </div>
195
+ )
196
+ }
@@ -73,6 +73,8 @@ export interface EditorPost {
73
73
  featuredImage: string
74
74
  body: string
75
75
  category: string
76
+ /** Editorial tags as plain strings (stored as `[{ tag }]` rows, see {@link parseTags}). */
77
+ tags: string[]
76
78
  status: PostStatus
77
79
  publishDate: string | null
78
80
  sections: PageSection[]
@@ -190,6 +192,26 @@ function dataStr(data: Record<string, unknown>, key: string): string {
190
192
  return typeof v === 'string' ? v : ''
191
193
  }
192
194
 
195
+ /**
196
+ * Read stored tags into plain strings. The `tagsField` preset stores
197
+ * `[{ tag: 'AI' }]` rows; older/hand-rolled configs may store `['AI']`
198
+ * directly — accept both so no site loses tags in the editor.
199
+ */
200
+ export function parseTags(raw: unknown): string[] {
201
+ if (!Array.isArray(raw)) return []
202
+ const out: string[] = []
203
+ for (const item of raw) {
204
+ const value =
205
+ typeof item === 'string'
206
+ ? item
207
+ : item && typeof item === 'object'
208
+ ? (item as { tag?: unknown }).tag
209
+ : undefined
210
+ if (typeof value === 'string' && value.trim()) out.push(value.trim())
211
+ }
212
+ return out
213
+ }
214
+
193
215
  function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
194
216
  const data = doc.data ?? {}
195
217
  return {
@@ -201,6 +223,7 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
201
223
  featuredImage: dataStr(data, 'featuredImage'),
202
224
  body: dataStr(data, 'body'),
203
225
  category: dataStr(data, 'category'),
226
+ tags: parseTags(data.tags),
204
227
  status: doc.status ?? 'DRAFT',
205
228
  publishDate: (data.publishDate as string) ?? doc.scheduledAt ?? null,
206
229
  // Preserve unknown (externally-managed) sections so the editor can show
@@ -290,6 +313,7 @@ interface PostWritePayload {
290
313
  featuredImage: string
291
314
  body: string
292
315
  category: string
316
+ tags: Array<{ tag: string }>
293
317
  publishDate: string | null
294
318
  sections: PageSection[]
295
319
  metaTitle: string
@@ -313,6 +337,9 @@ function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string
313
337
  featuredImage: post.featuredImage,
314
338
  body: post.body,
315
339
  category: post.category,
340
+ // Store the canonical `tagsField` row shape so server-side array-field
341
+ // validation and the public renderer see the schema they expect.
342
+ tags: post.tags.map((tag) => ({ tag })),
316
343
  publishDate: post.publishDate,
317
344
  // Unmanaged sections are part of `post.sections`, so they round-trip on
318
345
  // every save (no data loss).
@@ -378,6 +405,7 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
378
405
  featuredImage: '',
379
406
  body: '',
380
407
  category: '',
408
+ tags: [],
381
409
  status: 'DRAFT',
382
410
  publishDate: null,
383
411
  sections,
@@ -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,13 +4,27 @@ import { useEffect, useState } from 'react'
4
4
  import { Modal } from '../../components/ui/Modal.js'
5
5
  import { Button } from '../../components/ui/Button.js'
6
6
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
7
+ import { TagInput } from '../../components/TagInput.js'
7
8
  import { RichTextField } from '../../fields/RichTextField.js'
8
9
  import type { EditorPost } from '../../lib/post-editor-service.js'
9
10
 
11
+ /**
12
+ * The slice of a collection's field config the modal reads to pick controls:
13
+ * a `select` category renders a dropdown (structured vocabulary), an `array`
14
+ * tags field renders the chip input with autocomplete.
15
+ */
16
+ export interface PostFieldConfig {
17
+ type?: string
18
+ options?: Array<{ label: string; value: string }>
19
+ maxRows?: number
20
+ }
21
+
10
22
  interface PostFieldsModalProps {
11
23
  open: boolean
12
24
  post: EditorPost
13
25
  canEdit: boolean
26
+ /** Field definitions from the post type's collection config. */
27
+ fields?: Record<string, PostFieldConfig | undefined>
14
28
  onClose: () => void
15
29
  onSave: (patch: Partial<EditorPost>) => void
16
30
  }
@@ -27,19 +41,35 @@ function slugify(value: string): string {
27
41
  .replace(/^-+|-+$/g, '')
28
42
  }
29
43
 
30
- /** Edits post-level fields: title, slug, excerpt, featured image, category, body, SEO. */
31
- export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFieldsModalProps) {
44
+ /** Edits post-level fields: title, slug, excerpt, featured image, category, tags, body, SEO. */
45
+ export function PostFieldsModal({
46
+ open,
47
+ post,
48
+ canEdit,
49
+ fields,
50
+ onClose,
51
+ onSave,
52
+ }: PostFieldsModalProps) {
32
53
  const [title, setTitle] = useState(post.title)
33
54
  const [slug, setSlug] = useState(post.slug)
34
55
  const [excerpt, setExcerpt] = useState(post.excerpt)
35
56
  const [featuredImage, setFeaturedImage] = useState(post.featuredImage)
36
57
  const [category, setCategory] = useState(post.category)
58
+ const [tags, setTags] = useState<string[]>(post.tags)
37
59
  const [body, setBody] = useState(post.body)
38
60
  const [seoTitle, setSeoTitle] = useState(post.seoTitle)
39
61
  const [seoDescription, setSeoDescription] = useState(post.seoDescription)
40
62
  const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
41
63
  const [mediaOpen, setMediaOpen] = useState(false)
42
64
 
65
+ const categoryField = fields?.category
66
+ const categoryOptions =
67
+ categoryField?.type === 'select' && Array.isArray(categoryField.options)
68
+ ? categoryField.options
69
+ : null
70
+ const tagsField = fields?.tags
71
+ const hasTags = tagsField?.type === 'array'
72
+
43
73
  useEffect(() => {
44
74
  if (!open) return
45
75
  setTitle(post.title)
@@ -47,6 +77,7 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
47
77
  setExcerpt(post.excerpt)
48
78
  setFeaturedImage(post.featuredImage)
49
79
  setCategory(post.category)
80
+ setTags(post.tags)
50
81
  setBody(post.body)
51
82
  setSeoTitle(post.seoTitle)
52
83
  setSeoDescription(post.seoDescription)
@@ -59,7 +90,7 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
59
90
  }
60
91
 
61
92
  function handleSave() {
62
- onSave({ title, slug, excerpt, featuredImage, category, body, seoTitle, seoDescription })
93
+ onSave({ title, slug, excerpt, featuredImage, category, tags, body, seoTitle, seoDescription })
63
94
  onClose()
64
95
  }
65
96
 
@@ -112,16 +143,54 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
112
143
  <label className={LABEL} htmlFor="pf-category">
113
144
  Category
114
145
  </label>
115
- <input
116
- id="pf-category"
117
- className={INPUT}
118
- value={category}
146
+ {categoryOptions ? (
147
+ <select
148
+ id="pf-category"
149
+ className={INPUT}
150
+ value={category}
151
+ disabled={!canEdit}
152
+ onChange={(e) => setCategory(e.target.value)}
153
+ >
154
+ <option value="">No category</option>
155
+ {categoryOptions.map((o) => (
156
+ <option key={o.value} value={o.value}>
157
+ {o.label}
158
+ </option>
159
+ ))}
160
+ {/* Keep a legacy free-text value selectable so opening the
161
+ modal never silently discards it. */}
162
+ {category && !categoryOptions.some((o) => o.value === category) && (
163
+ <option value={category}>{category}</option>
164
+ )}
165
+ </select>
166
+ ) : (
167
+ <input
168
+ id="pf-category"
169
+ className={INPUT}
170
+ value={category}
171
+ disabled={!canEdit}
172
+ placeholder="e.g. Product"
173
+ onChange={(e) => setCategory(e.target.value)}
174
+ />
175
+ )}
176
+ </div>
177
+ </div>
178
+ {hasTags && (
179
+ <div>
180
+ <label className={LABEL} htmlFor="pf-tags">
181
+ Tags
182
+ </label>
183
+ <TagInput
184
+ id="pf-tags"
185
+ value={tags}
186
+ onChange={setTags}
187
+ collection={post.postType}
188
+ field="tags"
119
189
  disabled={!canEdit}
120
- placeholder="e.g. Product"
121
- onChange={(e) => setCategory(e.target.value)}
190
+ maxTags={tagsField?.maxRows}
122
191
  />
123
192
  </div>
124
- </div>
193
+ )}
125
194
  <div>
126
195
  <label className={LABEL} htmlFor="pf-excerpt">
127
196
  Excerpt