diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json
index ea6e255..c780ca3 100644
--- a/.contentrain/content/system/ui-strings/en.json
+++ b/.contentrain/content/system/ui-strings/en.json
@@ -276,7 +276,12 @@
"content.entry_count_filtered": "Showing {shown} of {matched} matches in {total} entries",
"content.entry_count_partial": "Showing {shown} of {total}",
"content.field_required": "This field is required",
+ "content.filter": "Filter",
+ "content.filter_clear": "Clear filters",
"content.filter_keys": "Filter keys or values...",
+ "content.filter_none": "This model has no fields that can be filtered.",
+ "content.filter_remove": "Remove filter {value}",
+ "content.filter_status": "Status",
"content.keep_editing": "Keep editing",
"content.key_column": "Key",
"content.keys_count": "keys",
@@ -312,6 +317,13 @@
"content.select_entry": "Select an entry",
"content.show_more": "Show more",
"content.slug_hint": "Lowercase letters, numbers, and hyphens only",
+ "content.sort": "Sort",
+ "content.sort_default": "Default order",
+ "content.sort_field_asc": "{field} ascending",
+ "content.sort_field_desc": "{field} descending",
+ "content.sort_title_asc": "Title A–Z",
+ "content.sort_title_desc": "Title Z–A",
+ "content.sort_updated": "Recently updated",
"content.stat_entries": "Entries",
"content.stat_locales": "Locales",
"content.stat_models": "Models",
diff --git a/app/components/molecules/ContentFilterBar.vue b/app/components/molecules/ContentFilterBar.vue
new file mode 100644
index 0000000..36a66a1
--- /dev/null
+++ b/app/components/molecules/ContentFilterBar.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ axis.label }}
+
+
+
+
+
+
+
+ {{ t('content.filter_none') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/organisms/ContentCollectionView.vue b/app/components/organisms/ContentCollectionView.vue
index ab865ce..c1189cb 100644
--- a/app/components/organisms/ContentCollectionView.vue
+++ b/app/components/organisms/ContentCollectionView.vue
@@ -58,13 +58,81 @@ const visibleCount = ref(PAGE_SIZE)
const allIds = computed(() => Object.keys(props.content))
+// ── Filter + sort ──────────────────────────────────────────
+// The axes come from the model, so they differ per model and cannot be
+// hardcoded. The full definition comes from the brain rather than the injected
+// meta, which carries only id/name/kind — `title_field` and `fields` are needed
+// here to title a sort and to derive the axes.
+const modelDefinition = computed(() =>
+ brain.models.value.find(m => m.id === props.modelId) ?? null,
+)
+
+const filterSelection = ref({})
+const sortBy = ref(SORT_DEFAULT)
+
+/**
+ * Human labels for relation targets, so a relation axis reads as titles rather
+ * than as `f3a81c09d24e`. Loaded when the model changes, not per render.
+ */
+const relationLabels = ref>>({})
+
+async function loadRelationLabels() {
+ const model = modelDefinition.value
+ const fields = (model?.fields ?? {}) as Record
+ const next: Record> = {}
+
+ for (const [fieldId, def] of Object.entries(fields)) {
+ if (def?.type !== 'relation' && def?.type !== 'relations') continue
+ const targets = Array.isArray(def.model) ? def.model : def.model ? [def.model] : []
+ const labels: Record = {}
+
+ for (const targetId of targets) {
+ const targetModel = brain.models.value.find(m => m.id === targetId) ?? null
+ const result = await brain.queryContent(targetId, props.locale ?? 'en')
+ const data = result?.data as Record> | Array> | null
+ if (Array.isArray(data)) {
+ for (const doc of data) {
+ const slug = doc.slug as string
+ if (slug) labels[slug] = resolveEntryTitle(doc, targetModel, slug)
+ }
+ }
+ else if (data) {
+ for (const [ref, entry] of Object.entries(data)) labels[ref] = resolveEntryTitle(entry, targetModel, ref)
+ }
+ }
+
+ if (Object.keys(labels).length > 0) next[fieldId] = labels
+ }
+
+ relationLabels.value = next
+}
+
+const filterAxes = computed(() => deriveFilterAxes({
+ model: modelDefinition.value,
+ content: props.content,
+ meta: props.meta as Record | null,
+ relationLabels: relationLabels.value,
+ t,
+}))
+
+const sortOptions = computed(() => deriveSortOptions({
+ model: modelDefinition.value,
+ meta: props.meta as Record | null,
+ hasStatusAxis: filterAxes.value.some(a => a.id === STATUS_AXIS_ID),
+ t,
+}))
+
+const activeFilterCount = computed(() =>
+ Object.values(filterSelection.value).reduce((sum, values) => sum + values.length, 0),
+)
+
/**
* Search runs against the brain's index, not the rendered rows — otherwise it
* would only ever find what is already on screen, which is the opposite of what
* it is for. The index holds every entry, so a match on page twenty is found
* without paging there.
*/
-const matchedIds = computed(() => {
+const searchedIds = computed(() => {
if (!searchQuery.value.trim()) return allIds.value
if (!searchIds.value) return []
// Intersect rather than trust the index: it is rebuilt per sync, so it can
@@ -73,6 +141,30 @@ const matchedIds = computed(() => {
return searchIds.value.filter(id => present.has(id))
})
+/**
+ * Search and filter compose as an intersection, then the result is ordered.
+ *
+ * Both work over the whole model — search through the index, filters through
+ * the in-memory payload — so neither is limited to the rows already rendered.
+ * The page limit is applied last, to whatever survived.
+ */
+const matchedIds = computed(() => {
+ const filtered = applyFilters(
+ searchedIds.value,
+ props.content,
+ props.meta as Record | null,
+ filterAxes.value,
+ filterSelection.value,
+ )
+ return sortIds(
+ filtered,
+ props.content,
+ props.meta as Record | null,
+ sortBy.value,
+ modelDefinition.value,
+ )
+})
+
/**
* An entry named by `?entry=` — the palette's search result — is pulled to the
* front and opened. Otherwise "go to this entry" would drop someone at the top
@@ -139,13 +231,23 @@ onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer)
})
-// A different model or locale is a different list; the old query and page
-// position mean nothing there.
+// A different model or locale is a different list; the old query, filters and
+// page position mean nothing there — one model's `category` is not another's.
watch(() => [props.modelId, props.locale], () => {
searchQuery.value = ''
searchIds.value = null
visibleCount.value = PAGE_SIZE
-})
+ filterSelection.value = {}
+ sortBy.value = SORT_DEFAULT
+ relationLabels.value = {}
+ void loadRelationLabels()
+}, { immediate: true })
+
+// Paging restarts whenever the surviving set changes, so page two of the old
+// filter is never page two of the new one.
+watch([filterSelection, sortBy], () => {
+ visibleCount.value = PAGE_SIZE
+}, { deep: true })
const getFieldType = inject(getFieldTypeKey, () => 'string')
const getEntryTitle = inject(getEntryTitleKey, (_e: Record, f: string) => f)
@@ -254,7 +356,7 @@ function onFieldDragStart(e: DragEvent, entryId: string, fieldId: string, value: