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 @@ + + + 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:
-
+
+ +
@@ -385,17 +497,31 @@ function onFieldDragStart(e: DragEvent, entryId: string, fieldId: string, value: :title="t('common.loading')" :description="t('content.searching_description')" /> + + + +
-